From a2ec0f468cdf2fd373ebe084b6d877059ecd3fd8 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 13:37:08 +1100 Subject: [PATCH 01/36] 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. --- README.md | 91 +++++++++ test/test_transport_options.py | 190 ++++++++++++++++++ uv.lock | 6 +- zitadel_client/__init__.py | 1 + zitadel_client/api_client.py | 2 + .../auth/client_credentials_authenticator.py | 15 +- zitadel_client/auth/oauth_authenticator.py | 10 +- zitadel_client/auth/open_id.py | 45 ++++- .../auth/web_token_authenticator.py | 23 ++- zitadel_client/configuration.py | 2 + zitadel_client/rest.py | 8 +- zitadel_client/transport_options.py | 16 ++ zitadel_client/zitadel.py | 116 ++++++++++- 13 files changed, 499 insertions(+), 26 deletions(-) create mode 100644 test/test_transport_options.py create mode 100644 zitadel_client/transport_options.py diff --git a/README.md b/README.md index 37baa8a3..55914b57 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,97 @@ Choose the authentication method that best suits your needs based on your environment and security requirements. For more details, please refer to the [Zitadel documentation on authenticating service users](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users). +## Advanced Configuration + +The SDK factory methods (`with_client_credentials`, `with_private_key`, +`with_access_token`) accept additional keyword arguments for advanced +transport configuration. + +### Disabling TLS Verification + +To disable TLS certificate verification (useful for development with +self-signed certificates), pass `insecure=True`: + +```python +import zitadel_client as zitadel + +client = zitadel.Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", + "client-secret", + insecure=True, +) +``` + +### Using a Custom CA Certificate + +To use a custom CA certificate for TLS verification, pass +`ca_cert_path` with the path to your CA certificate file: + +```python +import zitadel_client as zitadel + +client = zitadel.Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", + "client-secret", + ca_cert_path="/path/to/ca.pem", +) +``` + +### Custom Default Headers + +To send custom headers with every request, pass a `default_headers` +dictionary: + +```python +import zitadel_client as zitadel + +client = zitadel.Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", + "client-secret", + default_headers={"Proxy-Authorization": "Basic ..."}, +) +``` + +### Proxy Configuration + +To route all SDK traffic through an HTTP proxy, pass a `proxy_url`: + +```python +import zitadel_client as zitadel + +client = zitadel.Zitadel.with_client_credentials( + "https://example.us1.zitadel.cloud", + "client-id", + "client-secret", + proxy_url="http://proxy:8080", +) +``` + +### Using TransportOptions + +All transport settings can be combined into a single `TransportOptions` object: + +```python +from zitadel_client import Zitadel, TransportOptions + +options = TransportOptions( + insecure=True, + ca_cert_path="/path/to/ca.pem", + default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"}, + proxy_url="http://proxy:8080", +) + +zitadel = Zitadel.with_client_credentials( + "https://my-instance.zitadel.cloud", + "client-id", + "client-secret", + transport_options=options, +) +``` + ## Design and Dependencies This SDK is designed to be lean and efficient, focusing on providing a diff --git a/test/test_transport_options.py b/test/test_transport_options.py new file mode 100644 index 00000000..3d4e0a47 --- /dev/null +++ b/test/test_transport_options.py @@ -0,0 +1,190 @@ +import json +import os +import ssl +import tempfile +import time +import unittest +import urllib.request +from typing import Optional + +from testcontainers.core.container import DockerContainer + +from zitadel_client.transport_options import TransportOptions +from zitadel_client.zitadel import Zitadel + + +class TransportOptionsTest(unittest.TestCase): + """ + Test class for verifying transport options (default_headers, ca_cert_path, insecure) + on the Zitadel factory methods. + + This class starts a Docker container running WireMock with HTTPS support before any + tests run and stops it after all tests. It registers stubs for OpenID Configuration + discovery and the token endpoint so that Zitadel.with_client_credentials() can + complete its initialization flow. + """ + + host: Optional[str] = None + http_port: Optional[str] = None + https_port: Optional[str] = None + ca_cert_path: Optional[str] = None + wiremock: DockerContainer = None + + @classmethod + def setup_class(cls) -> None: + cls.wiremock = ( + DockerContainer("wiremock/wiremock:3.3.1") + .with_exposed_ports(8080, 8443) + .with_command("--https-port 8443 --global-response-templating") + ) + cls.wiremock.start() + + cls.host = cls.wiremock.get_container_host_ip() + cls.http_port = cls.wiremock.get_exposed_port(8080) + cls.https_port = cls.wiremock.get_exposed_port(8443) + + # Wait for WireMock to be ready by polling the admin API + admin_url = f"http://{cls.host}:{cls.http_port}/__admin/mappings" + for _ in range(30): + try: + with urllib.request.urlopen(admin_url, timeout=2) as resp: # noqa: S310 + if resp.status == 200: + break + except Exception: # noqa: S110, BLE001 + pass + time.sleep(1) + + # Register stub for OpenID Configuration discovery + oidc_stub = json.dumps( + { + "request": {"method": "GET", "url": "/.well-known/openid-configuration"}, + "response": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "body": ( + '{"issuer":"{{request.baseUrl}}",' + '"token_endpoint":"{{request.baseUrl}}/oauth/v2/token",' + '"authorization_endpoint":"{{request.baseUrl}}/oauth/v2/authorize",' + '"userinfo_endpoint":"{{request.baseUrl}}/oidc/v1/userinfo",' + '"jwks_uri":"{{request.baseUrl}}/oauth/v2/keys"}' + ), + }, + } + ).encode() + + req = urllib.request.Request( + f"http://{cls.host}:{cls.http_port}/__admin/mappings", + data=oidc_stub, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + assert resp.status == 201 + + # Register stub for the token endpoint + token_stub = json.dumps( + { + "request": {"method": "POST", "url": "/oauth/v2/token"}, + "response": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "jsonBody": { + "access_token": "test-token-12345", + "token_type": "Bearer", + "expires_in": 3600, + }, + }, + } + ).encode() + + req = urllib.request.Request( + f"http://{cls.host}:{cls.http_port}/__admin/mappings", + data=token_stub, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + assert resp.status == 201 + + # Extract the WireMock HTTPS certificate to a temp file + pem_cert = ssl.get_server_certificate((cls.host, int(cls.https_port))) + cert_file = tempfile.NamedTemporaryFile(suffix=".pem", delete=False) + cert_file.write(pem_cert.encode()) + cert_file.close() + cls.ca_cert_path = cert_file.name + + @classmethod + def teardown_class(cls) -> None: + if cls.ca_cert_path is not None: + os.unlink(cls.ca_cert_path) + if cls.wiremock is not None: + cls.wiremock.stop() + + def test_custom_ca_cert(self) -> None: + zitadel = Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + ca_cert_path=self.ca_cert_path, + ) + self.assertIsNotNone(zitadel) + + def test_insecure_mode(self) -> None: + zitadel = Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + insecure=True, + ) + self.assertIsNotNone(zitadel) + + def test_default_headers(self) -> None: + # Use HTTP to avoid TLS concerns + zitadel = Zitadel.with_client_credentials( + f"http://{self.host}:{self.http_port}", + "dummy-client", + "dummy-secret", + default_headers={"X-Custom-Header": "test-value"}, + ) + self.assertIsNotNone(zitadel) + + # Verify via WireMock request journal + journal_url = f"http://{self.host}:{self.http_port}/__admin/requests" + with urllib.request.urlopen(journal_url) as response: # noqa: S310 + journal = json.loads(response.read().decode()) + + found_header = False + for req in journal.get("requests", []): + headers = req.get("request", {}).get("headers", {}) + if "X-Custom-Header" in headers: + found_header = True + break + self.assertTrue(found_header, "Custom header should be present in WireMock request journal") + + def test_proxy_url(self) -> None: + # Use HTTP (not HTTPS) to avoid TLS complications with the proxy + zitadel = Zitadel.with_client_credentials( + f"http://{self.host}:{self.http_port}", + "dummy-client", + "dummy-secret", + proxy_url=f"http://{self.host}:{self.http_port}", + ) + self.assertIsNotNone(zitadel) + + def test_no_ca_cert_fails(self) -> None: + with self.assertRaises(Exception): # noqa: B017 + Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + ) + + def test_transport_options_object(self) -> None: + opts = TransportOptions(insecure=True) + zitadel = Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + transport_options=opts, + ) + self.assertIsNotNone(zitadel) diff --git a/uv.lock b/uv.lock index 0c115157..ac107801 100644 --- a/uv.lock +++ b/uv.lock @@ -1400,11 +1400,10 @@ wheels = [ [[package]] name = "zitadel-client" -version = "4.1.0b8" +version = "4.1.0b10" source = { editable = "." } dependencies = [ { name = "authlib" }, - { name = "cryptography" }, { name = "pydantic" }, { name = "python-dateutil" }, { name = "requests" }, @@ -1414,6 +1413,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "cryptography" }, { name = "fawltydeps" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1429,7 +1429,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.3.2,<2.0.0" }, - { name = "cryptography", specifier = ">=44.0.1,<47.0.0" }, { name = "pydantic", specifier = ">=2" }, { name = "python-dateutil", specifier = ">=2.8.2" }, { name = "requests", specifier = ">=2.32.4,<3.0.0" }, @@ -1439,6 +1438,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "cryptography", specifier = ">=44.0.1,<47.0.0" }, { name = "fawltydeps", specifier = "==0.19.0" }, { name = "pytest", specifier = ">=7.2.1" }, { name = "pytest-cov", specifier = ">=2.8.1" }, diff --git a/zitadel_client/__init__.py b/zitadel_client/__init__.py index 4b1be525..2a35cec6 100644 --- a/zitadel_client/__init__.py +++ b/zitadel_client/__init__.py @@ -8,4 +8,5 @@ ZitadelError, # noqa F401 ) from .models import * # noqa: F403, F401 +from .transport_options import TransportOptions # noqa F401 from .zitadel import Zitadel # noqa F401 diff --git a/zitadel_client/api_client.py b/zitadel_client/api_client.py index 94a6ce34..334baf05 100644 --- a/zitadel_client/api_client.py +++ b/zitadel_client/api_client.py @@ -65,6 +65,8 @@ def __init__( self.rest_client = rest.RESTClientObject(configuration) self.default_headers = {"User-Agent": configuration.user_agent} + if configuration.default_headers: + self.default_headers.update(configuration.default_headers) if header_name is not None and header_value is not None: self.default_headers[header_name] = header_value self.client_side_validation = configuration.client_side_validation diff --git a/zitadel_client/auth/client_credentials_authenticator.py b/zitadel_client/auth/client_credentials_authenticator.py index 58e19f38..99502af2 100644 --- a/zitadel_client/auth/client_credentials_authenticator.py +++ b/zitadel_client/auth/client_credentials_authenticator.py @@ -1,5 +1,5 @@ import sys -from typing import Dict, Set +from typing import Dict, Optional, Set if sys.version_info >= (3, 12): from typing import override @@ -13,6 +13,7 @@ OAuthAuthenticatorBuilder, ) from zitadel_client.auth.open_id import OpenId +from zitadel_client.transport_options import TransportOptions class ClientCredentialsAuthenticator(OAuthAuthenticator): @@ -50,16 +51,19 @@ def get_grant(self) -> Dict[str, str]: return {"grant_type": "client_credentials"} @staticmethod - def builder(host: str, client_id: str, client_secret: str) -> "ClientCredentialsAuthenticatorBuilder": + def builder( + host: str, client_id: str, client_secret: str, transport_options: Optional[TransportOptions] = None + ) -> "ClientCredentialsAuthenticatorBuilder": """ Returns a builder for constructing a ClientCredentialsAuthenticator. :param host: The base URL for the OAuth provider. :param client_id: The OAuth client identifier. :param client_secret: The OAuth client secret. + :param transport_options: Optional TransportOptions for configuring HTTP connections. :return: A ClientCredentialsAuthenticatorBuilder instance. """ - return ClientCredentialsAuthenticatorBuilder(host, client_id, client_secret) + return ClientCredentialsAuthenticatorBuilder(host, client_id, client_secret, transport_options=transport_options) class ClientCredentialsAuthenticatorBuilder(OAuthAuthenticatorBuilder["ClientCredentialsAuthenticatorBuilder"]): @@ -70,15 +74,16 @@ class ClientCredentialsAuthenticatorBuilder(OAuthAuthenticatorBuilder["ClientCre required for the client credentials flow. """ - def __init__(self, host: str, client_id: str, client_secret: str): + def __init__(self, host: str, client_id: str, client_secret: str, transport_options: Optional[TransportOptions] = None): """ Initializes the ClientCredentialsAuthenticatorBuilder with host, client ID, and client secret. :param host: The base URL for the OAuth provider. :param client_id: The OAuth client identifier. :param client_secret: The OAuth client secret. + :param transport_options: Optional TransportOptions for configuring HTTP connections. """ - super().__init__(host) + super().__init__(host, transport_options=transport_options) self.client_id = client_id self.client_secret = client_secret diff --git a/zitadel_client/auth/oauth_authenticator.py b/zitadel_client/auth/oauth_authenticator.py index 5ff5dd6e..148efa8c 100644 --- a/zitadel_client/auth/oauth_authenticator.py +++ b/zitadel_client/auth/oauth_authenticator.py @@ -8,6 +8,7 @@ from zitadel_client import ZitadelError from zitadel_client.auth.authenticator import Authenticator, Token from zitadel_client.auth.open_id import OpenId +from zitadel_client.transport_options import TransportOptions class OAuthAuthenticator(Authenticator, ABC): @@ -92,14 +93,19 @@ class OAuthAuthenticatorBuilder(ABC, Generic[T]): This builder provides common configuration options such as the OpenId instance and authentication scopes. """ - def __init__(self, host: str): + def __init__( + self, + host: str, + transport_options: Optional[TransportOptions] = None, + ): """ Initializes the OAuthAuthenticatorBuilder with a given host. :param host: The base URL for the OAuth provider. + :param transport_options: Optional TransportOptions for configuring HTTP connections. """ super().__init__() - self.open_id = OpenId(host) + self.open_id = OpenId(host, transport_options=transport_options) self.auth_scopes = {"openid", "urn:zitadel:iam:org:project:id:zitadel:aud"} def scopes(self: T, *auth_scopes: str) -> T: diff --git a/zitadel_client/auth/open_id.py b/zitadel_client/auth/open_id.py index a352d953..192ee953 100644 --- a/zitadel_client/auth/open_id.py +++ b/zitadel_client/auth/open_id.py @@ -1,9 +1,13 @@ import json +import ssl import urllib import urllib.error import urllib.request +from typing import Optional from urllib.parse import urljoin +from zitadel_client.transport_options import TransportOptions + class OpenId: """ @@ -16,7 +20,14 @@ class OpenId: host_endpoint: str token_endpoint: str - def __init__(self, hostname: str): + def __init__( # noqa: C901 + self, + hostname: str, + transport_options: Optional[TransportOptions] = None, + ): + if transport_options is None: + transport_options = TransportOptions.defaults() + # noinspection HttpUrlsUsage if not (hostname.startswith("http://") or hostname.startswith("https://")): hostname = "https://" + hostname @@ -29,7 +40,37 @@ def __init__(self, hostname: str): if not well_known_url.lower().startswith(("http://", "https://")): raise ValueError("Invalid URL scheme. Only 'http' and 'https' are allowed.") - with urllib.request.urlopen(well_known_url) as response: # noqa S310 + request = urllib.request.Request(well_known_url) # noqa: S310 + if transport_options.default_headers: + for header_name, header_value in transport_options.default_headers.items(): + request.add_header(header_name, header_value) + + if transport_options.insecure: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif transport_options.ca_cert_path: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.load_verify_locations(transport_options.ca_cert_path) + else: + ctx = None + + if transport_options.proxy_url: + proxy_handler = urllib.request.ProxyHandler( + {"http": transport_options.proxy_url, "https": transport_options.proxy_url} + ) + if ctx: + https_handler = urllib.request.HTTPSHandler(context=ctx) + opener = urllib.request.build_opener(proxy_handler, https_handler) + else: + opener = urllib.request.build_opener(proxy_handler) + response_ctx = opener.open(request) + else: + response_ctx = urllib.request.urlopen(request, context=ctx) # noqa: S310 + + with response_ctx as response: if response.status != 200: raise Exception(f"Failed to fetch OpenID configuration: HTTP {response.status}") config = json.loads(response.read().decode("utf-8")) diff --git a/zitadel_client/auth/web_token_authenticator.py b/zitadel_client/auth/web_token_authenticator.py index da2a6eac..44e30be5 100644 --- a/zitadel_client/auth/web_token_authenticator.py +++ b/zitadel_client/auth/web_token_authenticator.py @@ -10,6 +10,7 @@ OAuthAuthenticatorBuilder, ) from zitadel_client.auth.open_id import OpenId +from zitadel_client.transport_options import TransportOptions class WebTokenAuthenticator(OAuthAuthenticator): @@ -87,7 +88,9 @@ def get_grant(self) -> Dict[str, str]: raise Exception("Failed to generate JWT assertion: " + str(e)) from e @classmethod - def from_json(cls, host: str, json_path: str) -> "WebTokenAuthenticator": + def from_json( + cls, host: str, json_path: str, transport_options: Optional[TransportOptions] = None + ) -> "WebTokenAuthenticator": """ Create a WebTokenAuthenticatorBuilder instance from a JSON configuration file. @@ -101,6 +104,7 @@ def from_json(cls, host: str, json_path: str) -> "WebTokenAuthenticator": :param host: Base URL for the API endpoints. :param json_path: File path to the JSON configuration file. + :param transport_options: Optional TransportOptions for configuring HTTP connections. :return: A new instance of WebTokenAuthenticator. :raises Exception: If the file cannot be read, the JSON is invalid, or required keys are missing. @@ -117,19 +121,26 @@ def from_json(cls, host: str, json_path: str) -> "WebTokenAuthenticator": if not user_id or not key_id or not private_key: raise Exception("Missing required keys 'userId', 'key_id' or 'key' in JSON file.") - return (WebTokenAuthenticator.builder(host, user_id, private_key)).key_identifier(key_id).build() + return ( + (WebTokenAuthenticator.builder(host, user_id, private_key, transport_options=transport_options)) + .key_identifier(key_id) + .build() + ) @staticmethod - def builder(host: str, user_id: str, private_key: str) -> "WebTokenAuthenticatorBuilder": + def builder( + host: str, user_id: str, private_key: str, transport_options: Optional[TransportOptions] = None + ) -> "WebTokenAuthenticatorBuilder": """ Returns a builder for constructing a JWTAuthenticator. :param host: The base URL for the OAuth provider. :param user_id: The user identifier, used as both the issuer and subject. :param private_key: The private key used to sign the JWT. + :param transport_options: Optional TransportOptions for configuring HTTP connections. :return: A JWTAuthenticatorBuilder instance. """ - return WebTokenAuthenticatorBuilder(host, user_id, user_id, host, private_key) + return WebTokenAuthenticatorBuilder(host, user_id, user_id, host, private_key, transport_options=transport_options) class WebTokenAuthenticatorBuilder(OAuthAuthenticatorBuilder["WebTokenAuthenticatorBuilder"]): @@ -147,6 +158,7 @@ def __init__( jwt_audience: str, private_key: str, key_id: Optional[str] = None, + transport_options: Optional[TransportOptions] = None, ): """ Initializes the JWTAuthenticatorBuilder with required parameters. @@ -156,8 +168,9 @@ def __init__( :param jwt_subject: The subject claim for the JWT. :param jwt_audience: The audience claim for the JWT. :param private_key: The PEM-formatted private key used for signing the JWT. + :param transport_options: Optional TransportOptions for configuring HTTP connections. """ - super().__init__(host) + super().__init__(host, transport_options=transport_options) self.jwt_issuer = jwt_issuer self.jwt_subject = jwt_subject self.jwt_audience = jwt_audience diff --git a/zitadel_client/configuration.py b/zitadel_client/configuration.py index 577078f6..964cff7d 100644 --- a/zitadel_client/configuration.py +++ b/zitadel_client/configuration.py @@ -81,6 +81,8 @@ def __init__( self.socket_options = None self.datetime_format = "%Y-%m-%dT%H:%M:%S.%f%z" self.date_format = "%Y-%m-%d" + self.default_headers: Dict[str, str] = {} + self.proxy_url: Optional[str] = None def __deepcopy__(self, memo: Dict[int, Any]) -> Self: cls = self.__class__ diff --git a/zitadel_client/rest.py b/zitadel_client/rest.py index c3d569e8..0db82255 100644 --- a/zitadel_client/rest.py +++ b/zitadel_client/rest.py @@ -49,8 +49,12 @@ def __init__(self, configuration) -> None: # type: ignore # https pool manager self.pool_manager: urllib3.PoolManager - # noinspection PyArgumentList - self.pool_manager = urllib3.PoolManager(**pool_args) # ty: ignore[invalid-argument-type] + if configuration.proxy_url: + # noinspection PyArgumentList + self.pool_manager = urllib3.ProxyManager(configuration.proxy_url, **pool_args) # ty: ignore[invalid-argument-type] + else: + # noinspection PyArgumentList + self.pool_manager = urllib3.PoolManager(**pool_args) # ty: ignore[invalid-argument-type] def request( # noqa C901 too complex self, diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py new file mode 100644 index 00000000..a75ea1f2 --- /dev/null +++ b/zitadel_client/transport_options.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional + + +@dataclass(frozen=True) +class TransportOptions: + """Immutable transport options for configuring HTTP connections.""" + + default_headers: Dict[str, str] = field(default_factory=dict) + ca_cert_path: Optional[str] = None + insecure: bool = False + proxy_url: Optional[str] = None + + @staticmethod + def defaults() -> "TransportOptions": + return TransportOptions() diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index e6935171..00a2a1b3 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -1,5 +1,5 @@ from types import TracebackType -from typing import Callable, Optional, Type, TypeVar +from typing import Callable, Dict, Optional, Type, TypeVar from zitadel_client.api.action_service_api import ActionServiceApi from zitadel_client.api.application_service_api import ApplicationServiceApi @@ -36,6 +36,7 @@ from zitadel_client.auth.personal_access_token_authenticator import PersonalAccessTokenAuthenticator from zitadel_client.auth.web_token_authenticator import WebTokenAuthenticator from zitadel_client.configuration import Configuration +from zitadel_client.transport_options import TransportOptions class Zitadel: @@ -182,38 +183,139 @@ def __exit__( pass @staticmethod - def with_access_token(host: str, access_token: str) -> "Zitadel": + def _resolve_transport_options( + transport_options: Optional[TransportOptions], + default_headers: Optional[Dict[str, str]], + ca_cert_path: Optional[str], + insecure: bool, + proxy_url: Optional[str], + ) -> TransportOptions: + if transport_options is not None: + return transport_options + return TransportOptions( + default_headers=default_headers or {}, + ca_cert_path=ca_cert_path, + insecure=insecure, + proxy_url=proxy_url, + ) + + @staticmethod + def _apply_transport_options( + config: Configuration, + transport_options: TransportOptions, + ) -> None: + config.default_headers = transport_options.default_headers + if transport_options.ca_cert_path: + config.ssl_ca_cert = transport_options.ca_cert_path + if transport_options.insecure: + config.verify_ssl = False + if transport_options.proxy_url: + config.proxy_url = transport_options.proxy_url + + @staticmethod + def with_access_token( + host: str, + access_token: str, + *, + default_headers: Optional[Dict[str, str]] = None, + ca_cert_path: Optional[str] = None, + insecure: bool = False, + proxy_url: Optional[str] = None, + transport_options: Optional[TransportOptions] = None, + ) -> "Zitadel": """ Initialize the SDK with a Personal Access Token (PAT). :param host: API URL (e.g., "https://api.zitadel.example.com"). :param access_token: Personal Access Token for Bearer authentication. + :param default_headers: Optional dictionary of default headers to send with every request. + :param ca_cert_path: Optional path to a CA certificate file for SSL verification. + :param insecure: If True, disable SSL certificate verification. + :param proxy_url: Optional proxy URL for HTTP/HTTPS requests. + :param transport_options: Optional TransportOptions object (overrides individual params if provided). :return: Configured Zitadel client instance. :see: https://zitadel.com/docs/guides/integrate/service-users/personal-access-token """ - return Zitadel(PersonalAccessTokenAuthenticator(host, access_token)) + resolved = Zitadel._resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + + def mutate_config(config: Configuration) -> None: + Zitadel._apply_transport_options(config, resolved) + + return Zitadel(PersonalAccessTokenAuthenticator(host, access_token), mutate_config=mutate_config) @staticmethod - def with_client_credentials(host: str, client_id: str, client_secret: str) -> "Zitadel": + def with_client_credentials( + host: str, + client_id: str, + client_secret: str, + *, + default_headers: Optional[Dict[str, str]] = None, + ca_cert_path: Optional[str] = None, + insecure: bool = False, + proxy_url: Optional[str] = None, + transport_options: Optional[TransportOptions] = None, + ) -> "Zitadel": """ Initialize the SDK using OAuth2 Client Credentials flow. :param host: API URL. :param client_id: OAuth2 client identifier. :param client_secret: OAuth2 client secret. + :param default_headers: Optional dictionary of default headers to send with every request. + :param ca_cert_path: Optional path to a CA certificate file for SSL verification. + :param insecure: If True, disable SSL certificate verification. + :param proxy_url: Optional proxy URL for HTTP/HTTPS requests. + :param transport_options: Optional TransportOptions object (overrides individual params if provided). :return: Configured Zitadel client instance with token auto-refresh. :see: https://zitadel.com/docs/guides/integrate/service-users/client-credentials """ - return Zitadel(ClientCredentialsAuthenticator.builder(host, client_id, client_secret).build()) + resolved = Zitadel._resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + + authenticator = ClientCredentialsAuthenticator.builder( + host, + client_id, + client_secret, + transport_options=resolved, + ).build() + + def mutate_config(config: Configuration) -> None: + Zitadel._apply_transport_options(config, resolved) + + return Zitadel(authenticator, mutate_config=mutate_config) @staticmethod - def with_private_key(host: str, key_file: str) -> "Zitadel": + def with_private_key( + host: str, + key_file: str, + *, + default_headers: Optional[Dict[str, str]] = None, + ca_cert_path: Optional[str] = None, + insecure: bool = False, + proxy_url: Optional[str] = None, + transport_options: Optional[TransportOptions] = None, + ) -> "Zitadel": """ Initialize the SDK via Private Key JWT assertion. :param host: API URL. :param key_file: Path to service account JSON or PEM key file. + :param default_headers: Optional dictionary of default headers to send with every request. + :param ca_cert_path: Optional path to a CA certificate file for SSL verification. + :param insecure: If True, disable SSL certificate verification. + :param proxy_url: Optional proxy URL for HTTP/HTTPS requests. + :param transport_options: Optional TransportOptions object (overrides individual params if provided). :return: Configured Zitadel client instance using JWT assertion. :see: https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt """ - return Zitadel(WebTokenAuthenticator.from_json(host, key_file)) + resolved = Zitadel._resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + + authenticator = WebTokenAuthenticator.from_json( + host, + key_file, + transport_options=resolved, + ) + + def mutate_config(config: Configuration) -> None: + Zitadel._apply_transport_options(config, resolved) + + return Zitadel(authenticator, mutate_config=mutate_config) From 378d94b5a91299cdae2e6722097aa93e5a21b976 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:10:13 +1100 Subject: [PATCH 02/36] Fix hostname verification for custom CA certificates --- zitadel_client/auth/open_id.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zitadel_client/auth/open_id.py b/zitadel_client/auth/open_id.py index 192ee953..a13ef743 100644 --- a/zitadel_client/auth/open_id.py +++ b/zitadel_client/auth/open_id.py @@ -51,7 +51,7 @@ def __init__( # noqa: C901 ctx.verify_mode = ssl.CERT_NONE elif transport_options.ca_cert_path: ctx = ssl.create_default_context() - ctx.check_hostname = False + ctx.check_hostname = True ctx.verify_mode = ssl.CERT_REQUIRED ctx.load_verify_locations(transport_options.ca_cert_path) else: From 50142f6380c6ac5caa299456e2865b655075deb0 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:25:20 +1100 Subject: [PATCH 03/36] Fix contradictory README example showing insecure with ca_cert_path --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 55914b57..4b292999 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,6 @@ All transport settings can be combined into a single `TransportOptions` object: from zitadel_client import Zitadel, TransportOptions options = TransportOptions( - insecure=True, ca_cert_path="/path/to/ca.pem", default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"}, proxy_url="http://proxy:8080", From 5371a304fa4ded7f30a287defef4d1ba90038812 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 14:30:30 +1100 Subject: [PATCH 04/36] Make default_headers truly immutable in TransportOptions frozen=True on the dataclass only prevents field reassignment but the dict itself could still be mutated in-place. Now default_headers is wrapped in MappingProxyType on construction, making it a read-only view. --- zitadel_client/transport_options.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py index a75ea1f2..4c8d9cb9 100644 --- a/zitadel_client/transport_options.py +++ b/zitadel_client/transport_options.py @@ -1,16 +1,20 @@ from dataclasses import dataclass, field -from typing import Dict, Optional +from types import MappingProxyType +from typing import Mapping, Optional @dataclass(frozen=True) class TransportOptions: """Immutable transport options for configuring HTTP connections.""" - default_headers: Dict[str, str] = field(default_factory=dict) + default_headers: Mapping[str, str] = field(default_factory=dict) ca_cert_path: Optional[str] = None insecure: bool = False proxy_url: Optional[str] = None + def __post_init__(self) -> None: + object.__setattr__(self, "default_headers", MappingProxyType(dict(self.default_headers))) + @staticmethod def defaults() -> "TransportOptions": return TransportOptions() From dddd8a157caa7b1186393835927c2b5f2a61df5d Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 15:06:17 +1100 Subject: [PATCH 05/36] Apply transport options to OAuth token exchange requests --- .../auth/client_credentials_authenticator.py | 45 ++++++++++++++++--- zitadel_client/auth/oauth_authenticator.py | 12 ++++- .../auth/web_token_authenticator.py | 23 +++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/zitadel_client/auth/client_credentials_authenticator.py b/zitadel_client/auth/client_credentials_authenticator.py index 99502af2..ef830f07 100644 --- a/zitadel_client/auth/client_credentials_authenticator.py +++ b/zitadel_client/auth/client_credentials_authenticator.py @@ -23,7 +23,14 @@ class ClientCredentialsAuthenticator(OAuthAuthenticator): Uses client_id and client_secret to obtain an access token from the OAuth2 token endpoint. """ - def __init__(self, open_id: OpenId, client_id: str, client_secret: str, auth_scopes: Set[str]): + def __init__( + self, + open_id: OpenId, + client_id: str, + client_secret: str, + auth_scopes: Set[str], + transport_options: Optional[TransportOptions] = None, + ): """ Constructs a ClientCredentialsAuthenticator. @@ -31,14 +38,32 @@ def __init__(self, open_id: OpenId, client_id: str, client_secret: str, auth_sco :param client_id: The OAuth client identifier. :param client_secret: The OAuth client secret. :param auth_scopes: The scope(s) for the token request. + :param transport_options: Optional TransportOptions for configuring HTTP connections. """ + opts = transport_options or TransportOptions.defaults() + + session_kwargs: Dict[str, object] = {} + if opts.insecure: + session_kwargs["verify"] = False + elif opts.ca_cert_path: + session_kwargs["verify"] = opts.ca_cert_path + if opts.proxy_url: + session_kwargs["proxies"] = {"http": opts.proxy_url, "https": opts.proxy_url} + + session = OAuth2Session( + client_id=client_id, + client_secret=client_secret, + scope=" ".join(auth_scopes), + **session_kwargs, + ) + + if opts.default_headers: + session.headers.update(opts.default_headers) + super().__init__( open_id, - OAuth2Session( - client_id=client_id, - client_secret=client_secret, - scope=" ".join(auth_scopes), - ), + session, + transport_options=opts, ) @override @@ -93,4 +118,10 @@ def build(self) -> ClientCredentialsAuthenticator: :return: A configured ClientCredentialsAuthenticator. """ - return ClientCredentialsAuthenticator(self.open_id, self.client_id, self.client_secret, self.auth_scopes) + return ClientCredentialsAuthenticator( + self.open_id, + self.client_id, + self.client_secret, + self.auth_scopes, + transport_options=self.transport_options, + ) diff --git a/zitadel_client/auth/oauth_authenticator.py b/zitadel_client/auth/oauth_authenticator.py index 148efa8c..6bca0eed 100644 --- a/zitadel_client/auth/oauth_authenticator.py +++ b/zitadel_client/auth/oauth_authenticator.py @@ -20,16 +20,23 @@ class OAuthAuthenticator(Authenticator, ABC): oauth_session: An OAuth2Session instance used for fetching tokens. """ - def __init__(self, open_id: OpenId, oauth_session: OAuth2Session): + def __init__( + self, + open_id: OpenId, + oauth_session: OAuth2Session, + transport_options: Optional[TransportOptions] = None, + ): """ Constructs an OAuthAuthenticator. :param open_id: An object that must implement get_host_endpoint() and get_token_endpoint(). :param oauth_session: The scope for the token request. + :param transport_options: Optional TransportOptions for configuring HTTP connections. """ super().__init__(open_id.get_host_endpoint()) self.open_id = open_id self.token: Optional[Token] = None + self.transport_options = transport_options or TransportOptions.defaults() self.oauth_session = oauth_session self._lock = Lock() @@ -105,7 +112,8 @@ def __init__( :param transport_options: Optional TransportOptions for configuring HTTP connections. """ super().__init__() - self.open_id = OpenId(host, transport_options=transport_options) + self.transport_options = transport_options or TransportOptions.defaults() + self.open_id = OpenId(host, transport_options=self.transport_options) self.auth_scopes = {"openid", "urn:zitadel:iam:org:project:id:zitadel:aud"} def scopes(self: T, *auth_scopes: str) -> T: diff --git a/zitadel_client/auth/web_token_authenticator.py b/zitadel_client/auth/web_token_authenticator.py index 44e30be5..f065b6f7 100644 --- a/zitadel_client/auth/web_token_authenticator.py +++ b/zitadel_client/auth/web_token_authenticator.py @@ -31,6 +31,7 @@ def __init__( jwt_lifetime: timedelta = timedelta(hours=1), jwt_algorithm: str = "RS256", key_id: Optional[str] = None, + transport_options: Optional[TransportOptions] = None, ): """ Constructs a JWTAuthenticator. @@ -43,8 +44,27 @@ def __init__( :param private_key: The private key used to sign the JWT. :param jwt_lifetime: Lifetime of the JWT in seconds. :param jwt_algorithm: The JWT signing algorithm (default "RS256"). + :param transport_options: Optional TransportOptions for configuring HTTP connections. """ - super().__init__(open_id, OAuth2Session(scope=" ".join(auth_scopes))) + opts = transport_options or TransportOptions.defaults() + + session_kwargs: Dict[str, object] = {} + if opts.insecure: + session_kwargs["verify"] = False + elif opts.ca_cert_path: + session_kwargs["verify"] = opts.ca_cert_path + if opts.proxy_url: + session_kwargs["proxies"] = {"http": opts.proxy_url, "https": opts.proxy_url} + + session = OAuth2Session( + scope=" ".join(auth_scopes), + **session_kwargs, + ) + + if opts.default_headers: + session.headers.update(opts.default_headers) + + super().__init__(open_id, session, transport_options=opts) self.jwt_issuer = jwt_issuer self.jwt_subject = jwt_subject self.jwt_audience = jwt_audience @@ -206,6 +226,7 @@ def build(self) -> WebTokenAuthenticator: private_key=self.private_key, jwt_lifetime=self.jwt_lifetime, key_id=self.key_id, + transport_options=self.transport_options, ) def key_identifier(self, key_id: Optional[str]) -> "WebTokenAuthenticatorBuilder": From 2eefe52b9a20d6ac602fc6bc9e9aa13ca137d419 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 18:57:00 +1100 Subject: [PATCH 06/36] Fix custom CA cert test and apply transport options to token exchange Use a pre-generated keystore with proper SANs (localhost, 127.0.0.1, ::1) for WireMock HTTPS instead of extracting certs at runtime. This fixes the hostname mismatch error on systems where localhost resolves to IPv6. Also threads transport options through to OAuth token exchange requests so that custom CA, insecure mode, proxy, and default headers apply end-to-end. --- test/fixtures/ca.pem | 20 ++++++++++++++++++++ test/fixtures/keystore.p12 | Bin 0 -> 2644 bytes test/test_transport_options.py | 25 +++++++++++++------------ 3 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 test/fixtures/ca.pem create mode 100644 test/fixtures/keystore.p12 diff --git a/test/fixtures/ca.pem b/test/fixtures/ca.pem new file mode 100644 index 00000000..7ef3bf71 --- /dev/null +++ b/test/fixtures/ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOjCCAiKgAwIBAgIUYtCHt3J95fUpagYaFNw8M1/oV7kwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDMwNDA1MTcwNVoYDzIxMjYw +MjA4MDUxNzA1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCVY1jORnqyVB9tUgYYo9U3uYCVtCzWt3lGCoxDpxAb +LlpNnqOxG33ugRbNTY/QBht37Q37PjBahMJxkRE7EPsqi2Bz2fsZMyB7pJgP5iTA +0cILFyFzGpgUkXjmtsozKy0jAHpnzHGALjtzoKgp4SxCrWSp/MYtfMkBP9xbEpf1 +IYYQyyiISgic0/vO+nUEjyR/ULFP+nd48KjOHwWIHqwMY3nuzqScshAsyIZzSRT0 +ND2TLK1rxGoITqsOg2yTxRWwP0khvE08Y/59BGfWZq0svBCp2E3sIXg2Z3hlie7o +n+3P0F00kQfrEvkTi/cHv2vuhJpnlHxmTgJBRwhWE2+xAgMBAAGjgYEwfzAdBgNV +HQ4EFgQUPOzmGXHMRu3zIZqKLad8EkHkZvowHwYDVR0jBBgwFoAUPOzmGXHMRu3z +IZqKLad8EkHkZvowDwYDVR0TAQH/BAUwAwEB/zAsBgNVHREEJTAjgglsb2NhbGhv +c3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggEBAD/z +IRzYSBp6qPrvVgIX5/mEwN6ylp1J1pTC8nPQRozg0X2SEnRxz1DGBa1l046QVew2 +3+LuGYWtVkTzEtiX7BN2jSshX8d8Ss73+psZOye6t8VcAmEeVVdnqU+EzVAhM1DP +mUiNxJPHgK2cZkpV2BHB0Ccu7qVfaIFvTk2OdbGOsQ7+r2l562kUDzCFvBo/mskO +xiIt3YMZrpyLJJzvgi+fIo351oqLvTKOHw30FelAPIHo/A2OgngsM31HvwxROYlr +C5mET6wnOtjTQbKORADTGQ8D3sJCjQJ/AI34Q4C2q/PBljVL8JKoAPzwviYAuqdd +NIIKpaYUzng24gw7+50= +-----END CERTIFICATE----- diff --git a/test/fixtures/keystore.p12 b/test/fixtures/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..9f1c66b18939d226680b40eadf982ab4f99daea2 GIT binary patch literal 2644 zcmai$c{CIX8^&jqL1m3Wb}`l&Ta&eDm|@I_YaJD`lx^&5p(!GnvS!PgwVJsSB8?$r zD|^T`uCjNthHsRwd%o|~pZAaVyytnI_nh~?-+7P}E-MfajHGaJaKMxjunD_Ifk%Kv z6xc3=0^34TU|*3Gi1F`;qlg00{AsC!fPkM%@pl45+W!*Jqeyck;Q&)T! z3Q#BKUD!Jwx)FsbBUTYpL3lO7`ME6rkL@kJ$+RtG?FFUVN|4qscM!REr>QdBCZ}e0 z{Dt@|dBX!MLhDej<?IydZm8qsXVrDC|;5oUjq?+RvDZ_NJk*bW63)j#2k8_Pjqw|NbSXE=;)PkV;6bjhCEOKEg-6uB$9wR7lAH%_?F z>aT@S=p0>(;YCbyD{sjxT;YsItP)+)To` zfaZ1tMov63DRv{>iBI|Oi(a&hx{$rt)pq{8>*DXLPa;yl<7xD_0DYqlz<6c)HM&wa z6QwV|4~hy|oJfmX8U9#8(*Sxr_)jzAi}%+$?YeFQvyS-r{H?fqNI6af{ke^(ihGHy z^zP@p%Pn1(s|IF$G_m5XLLM8p;w5zy&QNO~ryFi}68)?stj>mvTDZA-AcFVrxVzdV zXP2nKhUx$J;--<7EN*1{=|cu1LG~mN!t)N{ip97NwYB#z-ae1lOo%Vl4g}_ zx`55U{WiVPMyV!BtBm!t`OU#bP8~8_%DEaZ-R)sn;JFLMHeTUdtJ{8!DKurW<=U4O z0iw&qa66g5eF5qaU6>ItoXfwo;nisCel$PAwRoo+uNBS{SMPgW`|gCMjY@(tm+IBM zQH4uXhZ&8sQlZC~-Zqz`m~TruEw`e_PFwj)?3J7`_$r|vx4T{Lof}=|&c_NUC9Sf1 z$XF@0p=F%yK4H4IZUF&66Tq?wr>{(FD8XL!F1;fkxwn6Mq*A4xW|1RsdzL{-=JUFn zZRI8Ee8k_JLDE8tFsnat*Sb~Rj!rHx!J8T5-UXH-up%b*tE|0qZ%Y-Yq>2rMvkgS$ zm+|&&4h2;3`9%%Ng2?@B0z^xPsXl?EK+Asd;2*$1vmg{`+E1JMbMA6*|DQcPTtLvz z5cShk{WqYn`G=YWJh`*}6)3hZ8t+BnK2)&NC#Elb@X>)#AOmKZN$B3o9ZbnX-<66# z*vxvoSYfn7+I!tJ$W-o2-1>aPa?uF=$93pUBL^^3%d+2Uqno^Gm)}XWe`9y~L@&sj z)t-4#pl-h7RlBPjf3Do^2Fuk`_Sj`%JKWR4tX9h((~T7_R+h4TeX!D-Ak7D>;rj%w zf|L~L=NDx9W!)XxCrXx5;G3bVs`7+){u9R&*-S#RV7~1IOrfk1ukoOu@%a)VOxl{7EVjXw!Ah0Fo;!gA7V(DJ<5gS}8Mh(%;&FCFwS6QNspe%UD z+MxJi-4D$dKU%k>g1a$zt-M{&+Rq*@7vhp=6IT&qr09c!GL3!~%1!|7MI+5fffbx1 zb;y{{YZyA$)TvRS+Ln0b$y9>sfIx+^aO0?%@jLnePvwz60(mjld9{s00lO_@%EG^SnSyBjBe`6n6 z)3-zV-Y=}T@!8~N-I}tb1Lt?vNyymbhXi?yx&Y1k{_*S&D&?Wz`=zpZ;wz4AKbl{H zID@k(S-V$oY)9U;S7*yn>Ko_Ke=@)fGjwl_=Dw_=^~@zAf}=r@%ZgR}I$mYXoW1!D>IVA`C8@np*O}j@XvgKM z$4{h2zG{yGN_#Q`B#?{OGwIQnpPx}uy~G$a96K>Qr2SUt&W$R9LTsuLM5de4%Rj`R z+I(>QtGi#I+dqddkVidxJxR|uBbH(pYsRO=Dm{3=Fd}L2X#;&@okwWN@907-XN?C? zA=xaUMsm!N_!{m<#!HcHGGmWLB21(;@HEo(F!sp}8DDc?aD6_F;YxTf!CgHee7WJp z3%$jyoo6wHa!&kY(_XUu6~<GT93fHyVA~-J*lW zW}MuMV-3HDmbN!i!*B>27e3Z`xWKE*9#zU+Wlnb1!##-<(R)b<@^Sy_u;~b`C!vDQ zs}w&jImN(BB$rLg{_pV`m>xA%0RLRDT!H4^<6$2GR!Qd(Wy~fB9kgL`z9)uflq#)E z_oTt0&aN_+FdwlE%OK($K!o;tR*zw7u4hg z9wzObJx1`=F4XyA=^A0raA@uL*s5MD>~fRIn38>FjSpfv;X25VINV%CMPAue&aSpq z<%E{dXg!m0iuuXl4gAr2Yi;x!1TDYoGmf=P=1hsFN|uoAf> literal 0 HcmV?d00001 diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 3d4e0a47..91bee55e 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -1,7 +1,5 @@ import json import os -import ssl -import tempfile import time import unittest import urllib.request @@ -12,6 +10,8 @@ from zitadel_client.transport_options import TransportOptions from zitadel_client.zitadel import Zitadel +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + class TransportOptionsTest(unittest.TestCase): """ @@ -32,10 +32,20 @@ class TransportOptionsTest(unittest.TestCase): @classmethod def setup_class(cls) -> None: + cls.ca_cert_path = os.path.join(FIXTURES_DIR, "ca.pem") + keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12") + cls.wiremock = ( DockerContainer("wiremock/wiremock:3.3.1") .with_exposed_ports(8080, 8443) - .with_command("--https-port 8443 --global-response-templating") + .with_volume_mapping(keystore_path, "/home/wiremock/keystore.p12", mode="ro") + .with_command( + "--https-port 8443" + " --https-keystore /home/wiremock/keystore.p12" + " --keystore-password password" + " --keystore-type PKCS12" + " --global-response-templating" + ) ) cls.wiremock.start() @@ -106,17 +116,8 @@ def setup_class(cls) -> None: with urllib.request.urlopen(req) as resp: # noqa: S310 assert resp.status == 201 - # Extract the WireMock HTTPS certificate to a temp file - pem_cert = ssl.get_server_certificate((cls.host, int(cls.https_port))) - cert_file = tempfile.NamedTemporaryFile(suffix=".pem", delete=False) - cert_file.write(pem_cert.encode()) - cert_file.close() - cls.ca_cert_path = cert_file.name - @classmethod def teardown_class(cls) -> None: - if cls.ca_cert_path is not None: - os.unlink(cls.ca_cert_path) if cls.wiremock is not None: cls.wiremock.stop() From 078e4b60751ae245242024a41b748f148ed37efc Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 20:18:15 +1100 Subject: [PATCH 07/36] Standardize transport options tests across SDKs\n\nUse testcontainers HttpWaitStrategy instead of manual polling loop\nfor WireMock readiness, consistent with Java, Node, PHP, and Ruby. --- test/test_transport_options.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 91bee55e..105797e7 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -1,11 +1,11 @@ import json import os -import time import unittest import urllib.request from typing import Optional from testcontainers.core.container import DockerContainer +from testcontainers.core.wait_strategies import HttpWaitStrategy from zitadel_client.transport_options import TransportOptions from zitadel_client.zitadel import Zitadel @@ -46,6 +46,7 @@ def setup_class(cls) -> None: " --keystore-type PKCS12" " --global-response-templating" ) + .waiting_for(HttpWaitStrategy(8080, "/__admin/mappings").for_status_code(200)) ) cls.wiremock.start() @@ -53,17 +54,6 @@ def setup_class(cls) -> None: cls.http_port = cls.wiremock.get_exposed_port(8080) cls.https_port = cls.wiremock.get_exposed_port(8443) - # Wait for WireMock to be ready by polling the admin API - admin_url = f"http://{cls.host}:{cls.http_port}/__admin/mappings" - for _ in range(30): - try: - with urllib.request.urlopen(admin_url, timeout=2) as resp: # noqa: S310 - if resp.status == 200: - break - except Exception: # noqa: S110, BLE001 - pass - time.sleep(1) - # Register stub for OpenID Configuration discovery oidc_stub = json.dumps( { From e8218287ecf1ff9361efe569f5b336932114a498 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 20:23:50 +1100 Subject: [PATCH 08/36] Copy default_headers to mutable dict before assigning to config MappingProxyType from TransportOptions breaks Configuration's deepcopy and makes headers unexpectedly immutable. --- zitadel_client/zitadel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index 00a2a1b3..bc539ab9 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -204,7 +204,7 @@ def _apply_transport_options( config: Configuration, transport_options: TransportOptions, ) -> None: - config.default_headers = transport_options.default_headers + config.default_headers = dict(transport_options.default_headers) if transport_options.ca_cert_path: config.ssl_ca_cert = transport_options.ca_cert_path if transport_options.insecure: From 9a5dc7da1945cac02b504445d3d8055057efb281 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 21:14:48 +1100 Subject: [PATCH 09/36] Centralize OAuth session kwargs in TransportOptions Extract duplicated session kwargs building from authenticators into TransportOptions.to_session_kwargs(). --- .../auth/client_credentials_authenticator.py | 10 +--------- zitadel_client/auth/web_token_authenticator.py | 10 +--------- zitadel_client/transport_options.py | 11 +++++++++++ 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/zitadel_client/auth/client_credentials_authenticator.py b/zitadel_client/auth/client_credentials_authenticator.py index ef830f07..8a9936e9 100644 --- a/zitadel_client/auth/client_credentials_authenticator.py +++ b/zitadel_client/auth/client_credentials_authenticator.py @@ -42,19 +42,11 @@ def __init__( """ opts = transport_options or TransportOptions.defaults() - session_kwargs: Dict[str, object] = {} - if opts.insecure: - session_kwargs["verify"] = False - elif opts.ca_cert_path: - session_kwargs["verify"] = opts.ca_cert_path - if opts.proxy_url: - session_kwargs["proxies"] = {"http": opts.proxy_url, "https": opts.proxy_url} - session = OAuth2Session( client_id=client_id, client_secret=client_secret, scope=" ".join(auth_scopes), - **session_kwargs, + **opts.to_session_kwargs(), ) if opts.default_headers: diff --git a/zitadel_client/auth/web_token_authenticator.py b/zitadel_client/auth/web_token_authenticator.py index f065b6f7..0880e627 100644 --- a/zitadel_client/auth/web_token_authenticator.py +++ b/zitadel_client/auth/web_token_authenticator.py @@ -48,17 +48,9 @@ def __init__( """ opts = transport_options or TransportOptions.defaults() - session_kwargs: Dict[str, object] = {} - if opts.insecure: - session_kwargs["verify"] = False - elif opts.ca_cert_path: - session_kwargs["verify"] = opts.ca_cert_path - if opts.proxy_url: - session_kwargs["proxies"] = {"http": opts.proxy_url, "https": opts.proxy_url} - session = OAuth2Session( scope=" ".join(auth_scopes), - **session_kwargs, + **opts.to_session_kwargs(), ) if opts.default_headers: diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py index 4c8d9cb9..559b4b54 100644 --- a/zitadel_client/transport_options.py +++ b/zitadel_client/transport_options.py @@ -18,3 +18,14 @@ def __post_init__(self) -> None: @staticmethod def defaults() -> "TransportOptions": return TransportOptions() + + def to_session_kwargs(self) -> dict: + """Builds keyword arguments for an authlib OAuth2Session.""" + kwargs: dict = {} + if self.insecure: + kwargs["verify"] = False + elif self.ca_cert_path: + kwargs["verify"] = self.ca_cert_path + if self.proxy_url: + kwargs["proxies"] = {"http": self.proxy_url, "https": self.proxy_url} + return kwargs From 367dc2e9cdc4f95c46208a2571fdfe2fa7a15261 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 22:05:20 +1100 Subject: [PATCH 10/36] Verify default headers on API calls via WireMock verification Add WireMock stub for settings endpoint and use WireMock's /__admin/requests/count API to assert custom headers are sent on actual API calls, not just during initialization. --- test/test_transport_options.py | 55 ++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 105797e7..d74c18b7 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -106,6 +106,30 @@ def setup_class(cls) -> None: with urllib.request.urlopen(req) as resp: # noqa: S310 assert resp.status == 201 + # Register stub for Settings API endpoint (for verifying headers on API calls) + settings_stub = json.dumps( + { + "request": { + "method": "POST", + "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings", + }, + "response": { + "status": 200, + "headers": {"Content-Type": "application/json"}, + "jsonBody": {}, + }, + } + ).encode() + + req = urllib.request.Request( + f"http://{cls.host}:{cls.http_port}/__admin/mappings", + data=settings_stub, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + assert resp.status == 201 + @classmethod def teardown_class(cls) -> None: if cls.wiremock is not None: @@ -139,18 +163,25 @@ def test_default_headers(self) -> None: ) self.assertIsNotNone(zitadel) - # Verify via WireMock request journal - journal_url = f"http://{self.host}:{self.http_port}/__admin/requests" - with urllib.request.urlopen(journal_url) as response: # noqa: S310 - journal = json.loads(response.read().decode()) - - found_header = False - for req in journal.get("requests", []): - headers = req.get("request", {}).get("headers", {}) - if "X-Custom-Header" in headers: - found_header = True - break - self.assertTrue(found_header, "Custom header should be present in WireMock request journal") + # Make an actual API call to verify headers propagate to service requests + zitadel.settings.get_general_settings({}) + + # Use WireMock's verification API to assert the header was sent on the API call + verify_body = json.dumps( + { + "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings", + "headers": {"X-Custom-Header": {"equalTo": "test-value"}}, + } + ).encode() + req = urllib.request.Request( + f"http://{self.host}:{self.http_port}/__admin/requests/count", + data=verify_body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + result = json.loads(resp.read().decode()) + self.assertGreaterEqual(result["count"], 1, "Custom header should be present on API call") def test_proxy_url(self) -> None: # Use HTTP (not HTTPS) to avoid TLS complications with the proxy From 3c6cd4f62827a4621c72ef0f1d8286bc80cef1f1 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 22:36:19 +1100 Subject: [PATCH 11/36] Remove individual transport params from factory methods Factory methods now only accept a TransportOptions object instead of individual default_headers, ca_cert_path, insecure, and proxy_url parameters. This matches the Java and Node SDKs. --- README.md | 82 +++++----------------------------- test/test_transport_options.py | 18 ++------ zitadel_client/zitadel.py | 55 +++-------------------- 3 files changed, 23 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 4b292999..fc6720e2 100644 --- a/README.md +++ b/README.md @@ -198,76 +198,10 @@ environment and security requirements. For more details, please refer to the ## Advanced Configuration -The SDK factory methods (`with_client_credentials`, `with_private_key`, -`with_access_token`) accept additional keyword arguments for advanced -transport configuration. - -### Disabling TLS Verification - -To disable TLS certificate verification (useful for development with -self-signed certificates), pass `insecure=True`: - -```python -import zitadel_client as zitadel - -client = zitadel.Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", - "client-secret", - insecure=True, -) -``` - -### Using a Custom CA Certificate - -To use a custom CA certificate for TLS verification, pass -`ca_cert_path` with the path to your CA certificate file: - -```python -import zitadel_client as zitadel - -client = zitadel.Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", - "client-secret", - ca_cert_path="/path/to/ca.pem", -) -``` - -### Custom Default Headers - -To send custom headers with every request, pass a `default_headers` -dictionary: - -```python -import zitadel_client as zitadel - -client = zitadel.Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", - "client-secret", - default_headers={"Proxy-Authorization": "Basic ..."}, -) -``` - -### Proxy Configuration - -To route all SDK traffic through an HTTP proxy, pass a `proxy_url`: - -```python -import zitadel_client as zitadel - -client = zitadel.Zitadel.with_client_credentials( - "https://example.us1.zitadel.cloud", - "client-id", - "client-secret", - proxy_url="http://proxy:8080", -) -``` - -### Using TransportOptions - -All transport settings can be combined into a single `TransportOptions` object: +All factory methods (`with_client_credentials`, `with_private_key`, +`with_access_token`) accept an optional `transport_options` parameter +for configuring TLS, proxies, and default headers via a `TransportOptions` +object. ```python from zitadel_client import Zitadel, TransportOptions @@ -276,6 +210,7 @@ options = TransportOptions( ca_cert_path="/path/to/ca.pem", default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"}, proxy_url="http://proxy:8080", + insecure=False, ) zitadel = Zitadel.with_client_credentials( @@ -286,6 +221,13 @@ zitadel = Zitadel.with_client_credentials( ) ``` +Available options: + +- `ca_cert_path` — path to a custom CA certificate for TLS verification +- `insecure` — disable TLS certificate verification (not recommended for production) +- `default_headers` — dictionary of headers to include in every HTTP request +- `proxy_url` — HTTP proxy URL for all requests + ## Design and Dependencies This SDK is designed to be lean and efficient, focusing on providing a diff --git a/test/test_transport_options.py b/test/test_transport_options.py index d74c18b7..f11ca8a2 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -140,7 +140,7 @@ def test_custom_ca_cert(self) -> None: f"https://{self.host}:{self.https_port}", "dummy-client", "dummy-secret", - ca_cert_path=self.ca_cert_path, + transport_options=TransportOptions(ca_cert_path=self.ca_cert_path), ) self.assertIsNotNone(zitadel) @@ -149,7 +149,7 @@ def test_insecure_mode(self) -> None: f"https://{self.host}:{self.https_port}", "dummy-client", "dummy-secret", - insecure=True, + transport_options=TransportOptions(insecure=True), ) self.assertIsNotNone(zitadel) @@ -159,7 +159,7 @@ def test_default_headers(self) -> None: f"http://{self.host}:{self.http_port}", "dummy-client", "dummy-secret", - default_headers={"X-Custom-Header": "test-value"}, + transport_options=TransportOptions(default_headers={"X-Custom-Header": "test-value"}), ) self.assertIsNotNone(zitadel) @@ -189,7 +189,7 @@ def test_proxy_url(self) -> None: f"http://{self.host}:{self.http_port}", "dummy-client", "dummy-secret", - proxy_url=f"http://{self.host}:{self.http_port}", + transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.http_port}"), ) self.assertIsNotNone(zitadel) @@ -200,13 +200,3 @@ def test_no_ca_cert_fails(self) -> None: "dummy-client", "dummy-secret", ) - - def test_transport_options_object(self) -> None: - opts = TransportOptions(insecure=True) - zitadel = Zitadel.with_client_credentials( - f"https://{self.host}:{self.https_port}", - "dummy-client", - "dummy-secret", - transport_options=opts, - ) - self.assertIsNotNone(zitadel) diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index bc539ab9..53f19dd9 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -1,5 +1,5 @@ from types import TracebackType -from typing import Callable, Dict, Optional, Type, TypeVar +from typing import Callable, Optional, Type, TypeVar from zitadel_client.api.action_service_api import ActionServiceApi from zitadel_client.api.application_service_api import ApplicationServiceApi @@ -182,23 +182,6 @@ def __exit__( """ pass - @staticmethod - def _resolve_transport_options( - transport_options: Optional[TransportOptions], - default_headers: Optional[Dict[str, str]], - ca_cert_path: Optional[str], - insecure: bool, - proxy_url: Optional[str], - ) -> TransportOptions: - if transport_options is not None: - return transport_options - return TransportOptions( - default_headers=default_headers or {}, - ca_cert_path=ca_cert_path, - insecure=insecure, - proxy_url=proxy_url, - ) - @staticmethod def _apply_transport_options( config: Configuration, @@ -217,10 +200,6 @@ def with_access_token( host: str, access_token: str, *, - default_headers: Optional[Dict[str, str]] = None, - ca_cert_path: Optional[str] = None, - insecure: bool = False, - proxy_url: Optional[str] = None, transport_options: Optional[TransportOptions] = None, ) -> "Zitadel": """ @@ -228,15 +207,11 @@ def with_access_token( :param host: API URL (e.g., "https://api.zitadel.example.com"). :param access_token: Personal Access Token for Bearer authentication. - :param default_headers: Optional dictionary of default headers to send with every request. - :param ca_cert_path: Optional path to a CA certificate file for SSL verification. - :param insecure: If True, disable SSL certificate verification. - :param proxy_url: Optional proxy URL for HTTP/HTTPS requests. - :param transport_options: Optional TransportOptions object (overrides individual params if provided). + :param transport_options: Optional TransportOptions for TLS, proxy, and header configuration. :return: Configured Zitadel client instance. :see: https://zitadel.com/docs/guides/integrate/service-users/personal-access-token """ - resolved = Zitadel._resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + resolved = transport_options or TransportOptions() def mutate_config(config: Configuration) -> None: Zitadel._apply_transport_options(config, resolved) @@ -249,10 +224,6 @@ def with_client_credentials( client_id: str, client_secret: str, *, - default_headers: Optional[Dict[str, str]] = None, - ca_cert_path: Optional[str] = None, - insecure: bool = False, - proxy_url: Optional[str] = None, transport_options: Optional[TransportOptions] = None, ) -> "Zitadel": """ @@ -261,15 +232,11 @@ def with_client_credentials( :param host: API URL. :param client_id: OAuth2 client identifier. :param client_secret: OAuth2 client secret. - :param default_headers: Optional dictionary of default headers to send with every request. - :param ca_cert_path: Optional path to a CA certificate file for SSL verification. - :param insecure: If True, disable SSL certificate verification. - :param proxy_url: Optional proxy URL for HTTP/HTTPS requests. - :param transport_options: Optional TransportOptions object (overrides individual params if provided). + :param transport_options: Optional TransportOptions for TLS, proxy, and header configuration. :return: Configured Zitadel client instance with token auto-refresh. :see: https://zitadel.com/docs/guides/integrate/service-users/client-credentials """ - resolved = Zitadel._resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + resolved = transport_options or TransportOptions() authenticator = ClientCredentialsAuthenticator.builder( host, @@ -288,10 +255,6 @@ def with_private_key( host: str, key_file: str, *, - default_headers: Optional[Dict[str, str]] = None, - ca_cert_path: Optional[str] = None, - insecure: bool = False, - proxy_url: Optional[str] = None, transport_options: Optional[TransportOptions] = None, ) -> "Zitadel": """ @@ -299,15 +262,11 @@ def with_private_key( :param host: API URL. :param key_file: Path to service account JSON or PEM key file. - :param default_headers: Optional dictionary of default headers to send with every request. - :param ca_cert_path: Optional path to a CA certificate file for SSL verification. - :param insecure: If True, disable SSL certificate verification. - :param proxy_url: Optional proxy URL for HTTP/HTTPS requests. - :param transport_options: Optional TransportOptions object (overrides individual params if provided). + :param transport_options: Optional TransportOptions for TLS, proxy, and header configuration. :return: Configured Zitadel client instance using JWT assertion. :see: https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt """ - resolved = Zitadel._resolve_transport_options(transport_options, default_headers, ca_cert_path, insecure, proxy_url) + resolved = transport_options or TransportOptions() authenticator = WebTokenAuthenticator.from_json( host, From c3556860852399e3188e310b65f1ac25fe73ba37 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 23:04:20 +1100 Subject: [PATCH 12/36] Use with_access_token for proxy test reliability WireMock cannot act as an HTTP proxy for OpenID discovery, so use with_access_token which does not trigger discovery during construction. --- test/test_transport_options.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index f11ca8a2..b86324c7 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -184,11 +184,9 @@ def test_default_headers(self) -> None: self.assertGreaterEqual(result["count"], 1, "Custom header should be present on API call") def test_proxy_url(self) -> None: - # Use HTTP (not HTTPS) to avoid TLS complications with the proxy - zitadel = Zitadel.with_client_credentials( + zitadel = Zitadel.with_access_token( f"http://{self.host}:{self.http_port}", - "dummy-client", - "dummy-secret", + "test-token", transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.http_port}"), ) self.assertIsNotNone(zitadel) From 6b62becf2de993731061a7dda21478308ebdd7f6 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 23:17:52 +1100 Subject: [PATCH 13/36] Fix HttpWaitStrategy import for testcontainers 3.7.1 HttpWaitStrategy is only available in testcontainers 4.x. Replace with manual HTTP wait using wait_container_is_ready decorator which is available in the pinned 3.7.1 version. --- test/test_transport_options.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index b86324c7..9e0353a8 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -5,7 +5,7 @@ from typing import Optional from testcontainers.core.container import DockerContainer -from testcontainers.core.wait_strategies import HttpWaitStrategy +from testcontainers.core.waiting_utils import wait_container_is_ready from zitadel_client.transport_options import TransportOptions from zitadel_client.zitadel import Zitadel @@ -13,6 +13,14 @@ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") +@wait_container_is_ready() +def _wait_for_wiremock(host: str, port: str) -> None: + url = f"http://{host}:{port}/__admin/mappings" + with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 + if resp.status != 200: + raise ConnectionError(f"WireMock not ready: {resp.status}") + + class TransportOptionsTest(unittest.TestCase): """ Test class for verifying transport options (default_headers, ca_cert_path, insecure) @@ -46,7 +54,6 @@ def setup_class(cls) -> None: " --keystore-type PKCS12" " --global-response-templating" ) - .waiting_for(HttpWaitStrategy(8080, "/__admin/mappings").for_status_code(200)) ) cls.wiremock.start() @@ -54,6 +61,8 @@ def setup_class(cls) -> None: cls.http_port = cls.wiremock.get_exposed_port(8080) cls.https_port = cls.wiremock.get_exposed_port(8443) + _wait_for_wiremock(cls.host, cls.http_port) + # Register stub for OpenID Configuration discovery oidc_stub = json.dumps( { From c51d432dfc3e1fb6eb6028f5dc6edda836393204 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Wed, 4 Mar 2026 23:47:25 +1100 Subject: [PATCH 14/36] Align README Advanced Configuration with canonical structure Use consistent subsection structure across all SDKs: intro paragraph, then separate sections for TLS, CA cert, headers, and proxy with identical explanatory text. --- README.md | 75 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fc6720e2..fe875c3d 100644 --- a/README.md +++ b/README.md @@ -198,35 +198,80 @@ environment and security requirements. For more details, please refer to the ## Advanced Configuration -All factory methods (`with_client_credentials`, `with_private_key`, -`with_access_token`) accept an optional `transport_options` parameter -for configuring TLS, proxies, and default headers via a `TransportOptions` -object. +The SDK provides a `TransportOptions` object that allows you to customise +the underlying HTTP transport used for both OpenID discovery and API calls. + +### Disabling TLS Verification + +In development or testing environments with self-signed certificates, you can +disable TLS verification entirely: ```python from zitadel_client import Zitadel, TransportOptions -options = TransportOptions( - ca_cert_path="/path/to/ca.pem", - default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"}, - proxy_url="http://proxy:8080", - insecure=False, +options = TransportOptions(insecure=True) + +zitadel = Zitadel.with_client_credentials( + "https://your-instance.zitadel.cloud", + "client-id", + "client-secret", + transport_options=options, +) +``` + +### Using a Custom CA Certificate + +If your Zitadel instance uses a certificate signed by a private CA, you can +provide the path to the CA certificate in PEM format: + +```python +from zitadel_client import Zitadel, TransportOptions + +options = TransportOptions(ca_cert_path="/path/to/ca.pem") + +zitadel = Zitadel.with_client_credentials( + "https://your-instance.zitadel.cloud", + "client-id", + "client-secret", + transport_options=options, ) +``` + +### Custom Default Headers + +You can attach default headers to every outgoing request. This is useful for +proxy authentication or custom routing headers: + +```python +from zitadel_client import Zitadel, TransportOptions + +options = TransportOptions(default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"}) zitadel = Zitadel.with_client_credentials( - "https://my-instance.zitadel.cloud", + "https://your-instance.zitadel.cloud", "client-id", "client-secret", transport_options=options, ) ``` -Available options: +### Proxy Configuration -- `ca_cert_path` — path to a custom CA certificate for TLS verification -- `insecure` — disable TLS certificate verification (not recommended for production) -- `default_headers` — dictionary of headers to include in every HTTP request -- `proxy_url` — HTTP proxy URL for all requests +If your environment requires routing traffic through an HTTP proxy, you can +specify the proxy URL: + +```python +from zitadel_client import Zitadel, TransportOptions + +options = TransportOptions(proxy_url="http://proxy:8080") + +zitadel = Zitadel.with_client_credentials( + "https://your-instance.zitadel.cloud", + "client-id", + "client-secret", + transport_options=options, +) +``` ## Design and Dependencies From 759c9a93cb8b5671bd14b29a1d868ce4c91793df Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 01:54:04 +1100 Subject: [PATCH 15/36] Add real proxy container to transport options test --- test/fixtures/tinyproxy.conf | 6 ++++++ test/test_transport_options.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/tinyproxy.conf diff --git a/test/fixtures/tinyproxy.conf b/test/fixtures/tinyproxy.conf new file mode 100644 index 00000000..78732bef --- /dev/null +++ b/test/fixtures/tinyproxy.conf @@ -0,0 +1,6 @@ +Port 8888 +Listen 0.0.0.0 +Timeout 600 +MaxClients 100 +Allow 0.0.0.0/0 +DisableViaHeader Yes diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 9e0353a8..1801fef4 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -4,6 +4,7 @@ import urllib.request from typing import Optional +import docker from testcontainers.core.container import DockerContainer from testcontainers.core.waiting_utils import wait_container_is_ready @@ -35,13 +36,20 @@ class TransportOptionsTest(unittest.TestCase): host: Optional[str] = None http_port: Optional[str] = None https_port: Optional[str] = None + proxy_port: Optional[str] = None ca_cert_path: Optional[str] = None wiremock: DockerContainer = None + proxy_docker = None + docker_network = None @classmethod def setup_class(cls) -> None: cls.ca_cert_path = os.path.join(FIXTURES_DIR, "ca.pem") keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12") + tinyproxy_conf = os.path.join(FIXTURES_DIR, "tinyproxy.conf") + + docker_client = docker.from_env() + cls.docker_network = docker_client.networks.create("zitadel-proxy-test") cls.wiremock = ( DockerContainer("wiremock/wiremock:3.3.1") @@ -57,9 +65,24 @@ def setup_class(cls) -> None: ) cls.wiremock.start() + # Connect WireMock to network with alias so the proxy can resolve it + wiremock_id = cls.wiremock._container.id + cls.docker_network.connect(wiremock_id, aliases=["wiremock"]) + + # Create proxy directly on the network so Docker DNS resolves 'wiremock' + cls.proxy_docker = docker_client.containers.run( + "vimagick/tinyproxy", + detach=True, + network="zitadel-proxy-test", + ports={"8888/tcp": None}, + volumes={tinyproxy_conf: {"bind": "/etc/tinyproxy/tinyproxy.conf", "mode": "ro"}}, + ) + cls.proxy_docker.reload() + cls.host = cls.wiremock.get_container_host_ip() cls.http_port = cls.wiremock.get_exposed_port(8080) cls.https_port = cls.wiremock.get_exposed_port(8443) + cls.proxy_port = cls.proxy_docker.ports["8888/tcp"][0]["HostPort"] _wait_for_wiremock(cls.host, cls.http_port) @@ -141,8 +164,13 @@ def setup_class(cls) -> None: @classmethod def teardown_class(cls) -> None: + if cls.proxy_docker is not None: + cls.proxy_docker.stop() + cls.proxy_docker.remove() if cls.wiremock is not None: cls.wiremock.stop() + if cls.docker_network is not None: + cls.docker_network.remove() def test_custom_ca_cert(self) -> None: zitadel = Zitadel.with_client_credentials( @@ -193,12 +221,14 @@ def test_default_headers(self) -> None: self.assertGreaterEqual(result["count"], 1, "Custom header should be present on API call") def test_proxy_url(self) -> None: + # Use Docker-internal hostname — only resolvable through the proxy's network zitadel = Zitadel.with_access_token( - f"http://{self.host}:{self.http_port}", + "http://wiremock:8080", "test-token", - transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.http_port}"), + transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.proxy_port}"), ) self.assertIsNotNone(zitadel) + zitadel.settings.get_general_settings({}) def test_no_ca_cert_fails(self) -> None: with self.assertRaises(Exception): # noqa: B017 From f4621494bc31aeaf459b281c4e603160d03e6276 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 11:45:44 +1100 Subject: [PATCH 16/36] chore: align docs and remove inline comments --- test/test_transport_options.py | 18 ------------------ zitadel_client/zitadel.py | 6 +++--- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 1801fef4..28dcdf78 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -23,15 +23,6 @@ def _wait_for_wiremock(host: str, port: str) -> None: class TransportOptionsTest(unittest.TestCase): - """ - Test class for verifying transport options (default_headers, ca_cert_path, insecure) - on the Zitadel factory methods. - - This class starts a Docker container running WireMock with HTTPS support before any - tests run and stops it after all tests. It registers stubs for OpenID Configuration - discovery and the token endpoint so that Zitadel.with_client_credentials() can - complete its initialization flow. - """ host: Optional[str] = None http_port: Optional[str] = None @@ -65,11 +56,9 @@ def setup_class(cls) -> None: ) cls.wiremock.start() - # Connect WireMock to network with alias so the proxy can resolve it wiremock_id = cls.wiremock._container.id cls.docker_network.connect(wiremock_id, aliases=["wiremock"]) - # Create proxy directly on the network so Docker DNS resolves 'wiremock' cls.proxy_docker = docker_client.containers.run( "vimagick/tinyproxy", detach=True, @@ -86,7 +75,6 @@ def setup_class(cls) -> None: _wait_for_wiremock(cls.host, cls.http_port) - # Register stub for OpenID Configuration discovery oidc_stub = json.dumps( { "request": {"method": "GET", "url": "/.well-known/openid-configuration"}, @@ -113,7 +101,6 @@ def setup_class(cls) -> None: with urllib.request.urlopen(req) as resp: # noqa: S310 assert resp.status == 201 - # Register stub for the token endpoint token_stub = json.dumps( { "request": {"method": "POST", "url": "/oauth/v2/token"}, @@ -138,7 +125,6 @@ def setup_class(cls) -> None: with urllib.request.urlopen(req) as resp: # noqa: S310 assert resp.status == 201 - # Register stub for Settings API endpoint (for verifying headers on API calls) settings_stub = json.dumps( { "request": { @@ -191,7 +177,6 @@ def test_insecure_mode(self) -> None: self.assertIsNotNone(zitadel) def test_default_headers(self) -> None: - # Use HTTP to avoid TLS concerns zitadel = Zitadel.with_client_credentials( f"http://{self.host}:{self.http_port}", "dummy-client", @@ -200,10 +185,8 @@ def test_default_headers(self) -> None: ) self.assertIsNotNone(zitadel) - # Make an actual API call to verify headers propagate to service requests zitadel.settings.get_general_settings({}) - # Use WireMock's verification API to assert the header was sent on the API call verify_body = json.dumps( { "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings", @@ -221,7 +204,6 @@ def test_default_headers(self) -> None: self.assertGreaterEqual(result["count"], 1, "Custom header should be present on API call") def test_proxy_url(self) -> None: - # Use Docker-internal hostname — only resolvable through the proxy's network zitadel = Zitadel.with_access_token( "http://wiremock:8080", "test-token", diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index 53f19dd9..258be29c 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -207,7 +207,7 @@ def with_access_token( :param host: API URL (e.g., "https://api.zitadel.example.com"). :param access_token: Personal Access Token for Bearer authentication. - :param transport_options: Optional TransportOptions for TLS, proxy, and header configuration. + :param transport_options: Optional transport options for TLS, proxy, headers. :return: Configured Zitadel client instance. :see: https://zitadel.com/docs/guides/integrate/service-users/personal-access-token """ @@ -232,7 +232,7 @@ def with_client_credentials( :param host: API URL. :param client_id: OAuth2 client identifier. :param client_secret: OAuth2 client secret. - :param transport_options: Optional TransportOptions for TLS, proxy, and header configuration. + :param transport_options: Optional transport options for TLS, proxy, headers. :return: Configured Zitadel client instance with token auto-refresh. :see: https://zitadel.com/docs/guides/integrate/service-users/client-credentials """ @@ -262,7 +262,7 @@ def with_private_key( :param host: API URL. :param key_file: Path to service account JSON or PEM key file. - :param transport_options: Optional TransportOptions for TLS, proxy, and header configuration. + :param transport_options: Optional transport options for TLS, proxy, headers. :return: Configured Zitadel client instance using JWT assertion. :see: https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt """ From 5709d1b7c762f522dd1f204059032205adae1581 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 12:07:33 +1100 Subject: [PATCH 17/36] style: fix formatting --- test/test_transport_options.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 28dcdf78..806dbb41 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -23,7 +23,6 @@ def _wait_for_wiremock(host: str, port: str) -> None: class TransportOptionsTest(unittest.TestCase): - host: Optional[str] = None http_port: Optional[str] = None https_port: Optional[str] = None From e29aa2e5cd3f08bae1e5b6b96082af4878f6de0d Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 14:45:58 +1100 Subject: [PATCH 18/36] fix: add docker to dev dependencies Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d8c6eb82..a6391ec7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "pytest-cov>=2.8.1", "tox>=3.9.0", "types-python-dateutil>=2.8.19.14", + "docker>=7.0.0,<8.0.0", "testcontainers==3.7.1", "python-dotenv==1.1.1", "ruff>=0.12.4", diff --git a/uv.lock b/uv.lock index ac107801..6fac4739 100644 --- a/uv.lock +++ b/uv.lock @@ -1414,6 +1414,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "cryptography" }, + { name = "docker" }, { name = "fawltydeps" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1439,6 +1440,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "cryptography", specifier = ">=44.0.1,<47.0.0" }, + { name = "docker", specifier = ">=7.0.0,<8.0.0" }, { name = "fawltydeps", specifier = "==0.19.0" }, { name = "pytest", specifier = ">=7.2.1" }, { name = "pytest-cov", specifier = ">=2.8.1" }, From 59e769eece5ed74d01ed25d6b094a571fdf754e3 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 15:34:12 +1100 Subject: [PATCH 19/36] replace tinyproxy with ubuntu/squid:6.10-24.10_beta --- test/fixtures/squid.conf | 3 +++ test/fixtures/tinyproxy.conf | 6 ------ test/test_transport_options.py | 10 +++++----- 3 files changed, 8 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/squid.conf delete mode 100644 test/fixtures/tinyproxy.conf diff --git a/test/fixtures/squid.conf b/test/fixtures/squid.conf new file mode 100644 index 00000000..f3e60d4b --- /dev/null +++ b/test/fixtures/squid.conf @@ -0,0 +1,3 @@ +http_port 3128 +acl all src all +http_access allow all diff --git a/test/fixtures/tinyproxy.conf b/test/fixtures/tinyproxy.conf deleted file mode 100644 index 78732bef..00000000 --- a/test/fixtures/tinyproxy.conf +++ /dev/null @@ -1,6 +0,0 @@ -Port 8888 -Listen 0.0.0.0 -Timeout 600 -MaxClients 100 -Allow 0.0.0.0/0 -DisableViaHeader Yes diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 806dbb41..6d13629e 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -36,7 +36,7 @@ class TransportOptionsTest(unittest.TestCase): def setup_class(cls) -> None: cls.ca_cert_path = os.path.join(FIXTURES_DIR, "ca.pem") keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12") - tinyproxy_conf = os.path.join(FIXTURES_DIR, "tinyproxy.conf") + squid_conf = os.path.join(FIXTURES_DIR, "squid.conf") docker_client = docker.from_env() cls.docker_network = docker_client.networks.create("zitadel-proxy-test") @@ -59,18 +59,18 @@ def setup_class(cls) -> None: cls.docker_network.connect(wiremock_id, aliases=["wiremock"]) cls.proxy_docker = docker_client.containers.run( - "vimagick/tinyproxy", + "ubuntu/squid:6.10-24.10_beta", detach=True, network="zitadel-proxy-test", - ports={"8888/tcp": None}, - volumes={tinyproxy_conf: {"bind": "/etc/tinyproxy/tinyproxy.conf", "mode": "ro"}}, + ports={"3128/tcp": None}, + volumes={squid_conf: {"bind": "/etc/squid/squid.conf", "mode": "ro"}}, ) cls.proxy_docker.reload() cls.host = cls.wiremock.get_container_host_ip() cls.http_port = cls.wiremock.get_exposed_port(8080) cls.https_port = cls.wiremock.get_exposed_port(8443) - cls.proxy_port = cls.proxy_docker.ports["8888/tcp"][0]["HostPort"] + cls.proxy_port = cls.proxy_docker.ports["3128/tcp"][0]["HostPort"] _wait_for_wiremock(cls.host, cls.http_port) From bc9301f28292bac7ccfd65a83e5480bb9a112f46 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 10:11:51 +0530 Subject: [PATCH 20/36] Update zitadel_client/auth/web_token_authenticator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- zitadel_client/auth/web_token_authenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zitadel_client/auth/web_token_authenticator.py b/zitadel_client/auth/web_token_authenticator.py index 0880e627..f80a9c1c 100644 --- a/zitadel_client/auth/web_token_authenticator.py +++ b/zitadel_client/auth/web_token_authenticator.py @@ -131,7 +131,7 @@ def from_json( private_key = config.get("key") key_id = config.get("keyId") if not user_id or not key_id or not private_key: - raise Exception("Missing required keys 'userId', 'key_id' or 'key' in JSON file.") + raise Exception("Missing required keys 'userId', 'keyId' or 'key' in JSON file.") return ( (WebTokenAuthenticator.builder(host, user_id, private_key, transport_options=transport_options)) From d7a506a40d39ddc8477087ce3341484e335b4483 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 15:47:19 +1100 Subject: [PATCH 21/36] use unique network name to avoid collisions --- test/test_transport_options.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 6d13629e..f14a75a1 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -2,6 +2,7 @@ import os import unittest import urllib.request +import uuid from typing import Optional import docker @@ -39,7 +40,8 @@ def setup_class(cls) -> None: squid_conf = os.path.join(FIXTURES_DIR, "squid.conf") docker_client = docker.from_env() - cls.docker_network = docker_client.networks.create("zitadel-proxy-test") + cls.network_name = f"zitadel-test-{uuid.uuid4().hex[:8]}" + cls.docker_network = docker_client.networks.create(cls.network_name) cls.wiremock = ( DockerContainer("wiremock/wiremock:3.3.1") @@ -61,7 +63,7 @@ def setup_class(cls) -> None: cls.proxy_docker = docker_client.containers.run( "ubuntu/squid:6.10-24.10_beta", detach=True, - network="zitadel-proxy-test", + network=cls.network_name, ports={"3128/tcp": None}, volumes={squid_conf: {"bind": "/etc/squid/squid.conf", "mode": "ro"}}, ) From e2af097eb7c54cba3ad16d2cda4f8a2ee36865ca Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 15:53:09 +1100 Subject: [PATCH 22/36] use testcontainers Network for auto-managed network lifecycle --- pyproject.toml | 3 +-- test/test_transport_options.py | 40 +++++++++++++++------------------- uv.lock | 25 ++++++--------------- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6391ec7..75c00ee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,8 +34,7 @@ dev = [ "pytest-cov>=2.8.1", "tox>=3.9.0", "types-python-dateutil>=2.8.19.14", - "docker>=7.0.0,<8.0.0", - "testcontainers==3.7.1", + "testcontainers>=4.14.0,<5.0.0", "python-dotenv==1.1.1", "ruff>=0.12.4", "sphinx==7.4.7", diff --git a/test/test_transport_options.py b/test/test_transport_options.py index f14a75a1..fa413331 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -2,11 +2,10 @@ import os import unittest import urllib.request -import uuid from typing import Optional -import docker from testcontainers.core.container import DockerContainer +from testcontainers.core.network import Network from testcontainers.core.waiting_utils import wait_container_is_ready from zitadel_client.transport_options import TransportOptions @@ -30,8 +29,8 @@ class TransportOptionsTest(unittest.TestCase): proxy_port: Optional[str] = None ca_cert_path: Optional[str] = None wiremock: DockerContainer = None - proxy_docker = None - docker_network = None + proxy: DockerContainer = None + network: Network = None @classmethod def setup_class(cls) -> None: @@ -39,12 +38,12 @@ def setup_class(cls) -> None: keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12") squid_conf = os.path.join(FIXTURES_DIR, "squid.conf") - docker_client = docker.from_env() - cls.network_name = f"zitadel-test-{uuid.uuid4().hex[:8]}" - cls.docker_network = docker_client.networks.create(cls.network_name) + cls.network = Network().create() cls.wiremock = ( DockerContainer("wiremock/wiremock:3.3.1") + .with_network(cls.network) + .with_network_aliases("wiremock") .with_exposed_ports(8080, 8443) .with_volume_mapping(keystore_path, "/home/wiremock/keystore.p12", mode="ro") .with_command( @@ -57,22 +56,18 @@ def setup_class(cls) -> None: ) cls.wiremock.start() - wiremock_id = cls.wiremock._container.id - cls.docker_network.connect(wiremock_id, aliases=["wiremock"]) - - cls.proxy_docker = docker_client.containers.run( - "ubuntu/squid:6.10-24.10_beta", - detach=True, - network=cls.network_name, - ports={"3128/tcp": None}, - volumes={squid_conf: {"bind": "/etc/squid/squid.conf", "mode": "ro"}}, + cls.proxy = ( + DockerContainer("ubuntu/squid:6.10-24.10_beta") + .with_network(cls.network) + .with_exposed_ports(3128) + .with_volume_mapping(squid_conf, "/etc/squid/squid.conf", mode="ro") ) - cls.proxy_docker.reload() + cls.proxy.start() cls.host = cls.wiremock.get_container_host_ip() cls.http_port = cls.wiremock.get_exposed_port(8080) cls.https_port = cls.wiremock.get_exposed_port(8443) - cls.proxy_port = cls.proxy_docker.ports["3128/tcp"][0]["HostPort"] + cls.proxy_port = cls.proxy.get_exposed_port(3128) _wait_for_wiremock(cls.host, cls.http_port) @@ -151,13 +146,12 @@ def setup_class(cls) -> None: @classmethod def teardown_class(cls) -> None: - if cls.proxy_docker is not None: - cls.proxy_docker.stop() - cls.proxy_docker.remove() + if cls.proxy is not None: + cls.proxy.stop() if cls.wiremock is not None: cls.wiremock.stop() - if cls.docker_network is not None: - cls.docker_network.remove() + if cls.network is not None: + cls.network.remove() def test_custom_ca_cert(self) -> None: zitadel = Zitadel.with_client_credentials( diff --git a/uv.lock b/uv.lock index 6f4a4366..b79bef59 100644 --- a/uv.lock +++ b/uv.lock @@ -412,18 +412,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] -[[package]] -name = "deprecation" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -1135,15 +1123,18 @@ wheels = [ [[package]] name = "testcontainers" -version = "3.7.1" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecation" }, { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, { name = "wrapt" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/37/38c595414d764cb1d9f3a0c907878c4146a21505ab974c63bcf3d8145807/testcontainers-3.7.1-py2.py3-none-any.whl", hash = "sha256:7f48cef4bf0ccd78f1a4534d4b701a003a3bace851f24eae58a32f9e3f0aeba0", size = 45321, upload-time = "2022-12-06T17:55:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, ] [[package]] @@ -1414,7 +1405,6 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "cryptography" }, - { name = "docker" }, { name = "fawltydeps" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1440,14 +1430,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "cryptography", specifier = ">=44.0.1,<47.0.0" }, - { name = "docker", specifier = ">=7.0.0,<8.0.0" }, { name = "fawltydeps", specifier = "==0.19.0" }, { name = "pytest", specifier = ">=7.2.1" }, { name = "pytest-cov", specifier = ">=2.8.1" }, { name = "python-dotenv", specifier = "==1.1.1" }, { name = "ruff", specifier = ">=0.12.4" }, { name = "sphinx", specifier = "==7.4.7" }, - { name = "testcontainers", specifier = "==3.7.1" }, + { name = "testcontainers", specifier = ">=4.14.0,<5.0.0" }, { name = "tox", specifier = ">=3.9.0" }, { name = "ty", specifier = ">=0.0.4" }, { name = "types-python-dateutil", specifier = ">=2.8.19.14" }, From c7d2f1be3ae01350acd151a71e7a11799938ae93 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 16:50:12 +1100 Subject: [PATCH 23/36] docs: fix proxy auth docs to use URL credentials instead of default headers --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe875c3d..f663ecc4 100644 --- a/README.md +++ b/README.md @@ -240,12 +240,12 @@ zitadel = Zitadel.with_client_credentials( ### Custom Default Headers You can attach default headers to every outgoing request. This is useful for -proxy authentication or custom routing headers: +custom routing or tracing headers: ```python from zitadel_client import Zitadel, TransportOptions -options = TransportOptions(default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"}) +options = TransportOptions(default_headers={"X-Custom-Header": "my-value"}) zitadel = Zitadel.with_client_credentials( "https://your-instance.zitadel.cloud", @@ -258,12 +258,13 @@ zitadel = Zitadel.with_client_credentials( ### Proxy Configuration If your environment requires routing traffic through an HTTP proxy, you can -specify the proxy URL: +specify the proxy URL. To authenticate with the proxy, embed the credentials +directly in the URL: ```python from zitadel_client import Zitadel, TransportOptions -options = TransportOptions(proxy_url="http://proxy:8080") +options = TransportOptions(proxy_url="http://user:pass@proxy:8080") zitadel = Zitadel.with_client_credentials( "https://your-instance.zitadel.cloud", From 231dafcee2d4138ada7e68fdf2dfe7a719c77bd9 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Thu, 5 Mar 2026 21:38:08 +1100 Subject: [PATCH 24/36] fix: add proxy container wait strategy to prevent flaky tests --- test/test_transport_options.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index fa413331..3b994c0a 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -6,6 +6,7 @@ from testcontainers.core.container import DockerContainer from testcontainers.core.network import Network +from testcontainers.core.wait_strategies import PortWaitStrategy from testcontainers.core.waiting_utils import wait_container_is_ready from zitadel_client.transport_options import TransportOptions @@ -61,6 +62,7 @@ def setup_class(cls) -> None: .with_network(cls.network) .with_exposed_ports(3128) .with_volume_mapping(squid_conf, "/etc/squid/squid.conf", mode="ro") + .waiting_for(PortWaitStrategy(3128)) ) cls.proxy.start() From da26daf466e6a214440fdcc2f3d426d77e4ea6ae Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 09:18:06 +1100 Subject: [PATCH 25/36] Add missing param docstrings for transport_options --- zitadel_client/auth/open_id.py | 6 ++++++ zitadel_client/transport_options.py | 8 +++++++- zitadel_client/zitadel.py | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/zitadel_client/auth/open_id.py b/zitadel_client/auth/open_id.py index a13ef743..30b18ce2 100644 --- a/zitadel_client/auth/open_id.py +++ b/zitadel_client/auth/open_id.py @@ -25,6 +25,12 @@ def __init__( # noqa: C901 hostname: str, transport_options: Optional[TransportOptions] = None, ): + """ + Initialize the OpenId configuration fetcher. + + :param hostname: The Zitadel instance hostname or URL. + :param transport_options: Optional transport options for TLS, proxy, and headers. + """ if transport_options is None: transport_options = TransportOptions.defaults() diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py index 559b4b54..2fd450d0 100644 --- a/zitadel_client/transport_options.py +++ b/zitadel_client/transport_options.py @@ -5,7 +5,13 @@ @dataclass(frozen=True) class TransportOptions: - """Immutable transport options for configuring HTTP connections.""" + """Immutable transport options for configuring HTTP connections. + + :param default_headers: Additional HTTP headers to include in every request. + :param ca_cert_path: Path to a custom CA certificate file for TLS verification. + :param insecure: If True, disables TLS certificate verification. + :param proxy_url: HTTP/HTTPS proxy URL to route requests through. + """ default_headers: Mapping[str, str] = field(default_factory=dict) ca_cert_path: Optional[str] = None diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index 258be29c..258387ca 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -187,6 +187,12 @@ def _apply_transport_options( config: Configuration, transport_options: TransportOptions, ) -> None: + """ + Apply transport options to the SDK configuration. + + :param config: The Configuration instance to modify. + :param transport_options: Optional transport options for TLS, proxy, and headers. + """ config.default_headers = dict(transport_options.default_headers) if transport_options.ca_cert_path: config.ssl_ca_cert = transport_options.ca_cert_path From 0dd6baa138196b3aae82bfeccbc11bb6cfc1aad0 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 09:23:54 +1100 Subject: [PATCH 26/36] Standardize :param transport_options: descriptions --- zitadel_client/auth/client_credentials_authenticator.py | 6 +++--- zitadel_client/auth/oauth_authenticator.py | 4 ++-- zitadel_client/auth/web_token_authenticator.py | 8 ++++---- zitadel_client/zitadel.py | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/zitadel_client/auth/client_credentials_authenticator.py b/zitadel_client/auth/client_credentials_authenticator.py index 8a9936e9..a95cd8b3 100644 --- a/zitadel_client/auth/client_credentials_authenticator.py +++ b/zitadel_client/auth/client_credentials_authenticator.py @@ -38,7 +38,7 @@ def __init__( :param client_id: The OAuth client identifier. :param client_secret: The OAuth client secret. :param auth_scopes: The scope(s) for the token request. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. """ opts = transport_options or TransportOptions.defaults() @@ -77,7 +77,7 @@ def builder( :param host: The base URL for the OAuth provider. :param client_id: The OAuth client identifier. :param client_secret: The OAuth client secret. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. :return: A ClientCredentialsAuthenticatorBuilder instance. """ return ClientCredentialsAuthenticatorBuilder(host, client_id, client_secret, transport_options=transport_options) @@ -98,7 +98,7 @@ def __init__(self, host: str, client_id: str, client_secret: str, transport_opti :param host: The base URL for the OAuth provider. :param client_id: The OAuth client identifier. :param client_secret: The OAuth client secret. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. """ super().__init__(host, transport_options=transport_options) self.client_id = client_id diff --git a/zitadel_client/auth/oauth_authenticator.py b/zitadel_client/auth/oauth_authenticator.py index 6bca0eed..195e6aa6 100644 --- a/zitadel_client/auth/oauth_authenticator.py +++ b/zitadel_client/auth/oauth_authenticator.py @@ -31,7 +31,7 @@ def __init__( :param open_id: An object that must implement get_host_endpoint() and get_token_endpoint(). :param oauth_session: The scope for the token request. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. """ super().__init__(open_id.get_host_endpoint()) self.open_id = open_id @@ -109,7 +109,7 @@ def __init__( Initializes the OAuthAuthenticatorBuilder with a given host. :param host: The base URL for the OAuth provider. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. """ super().__init__() self.transport_options = transport_options or TransportOptions.defaults() diff --git a/zitadel_client/auth/web_token_authenticator.py b/zitadel_client/auth/web_token_authenticator.py index f80a9c1c..c677b323 100644 --- a/zitadel_client/auth/web_token_authenticator.py +++ b/zitadel_client/auth/web_token_authenticator.py @@ -44,7 +44,7 @@ def __init__( :param private_key: The private key used to sign the JWT. :param jwt_lifetime: Lifetime of the JWT in seconds. :param jwt_algorithm: The JWT signing algorithm (default "RS256"). - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. """ opts = transport_options or TransportOptions.defaults() @@ -116,7 +116,7 @@ def from_json( :param host: Base URL for the API endpoints. :param json_path: File path to the JSON configuration file. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. :return: A new instance of WebTokenAuthenticator. :raises Exception: If the file cannot be read, the JSON is invalid, or required keys are missing. @@ -149,7 +149,7 @@ def builder( :param host: The base URL for the OAuth provider. :param user_id: The user identifier, used as both the issuer and subject. :param private_key: The private key used to sign the JWT. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. :return: A JWTAuthenticatorBuilder instance. """ return WebTokenAuthenticatorBuilder(host, user_id, user_id, host, private_key, transport_options=transport_options) @@ -180,7 +180,7 @@ def __init__( :param jwt_subject: The subject claim for the JWT. :param jwt_audience: The audience claim for the JWT. :param private_key: The PEM-formatted private key used for signing the JWT. - :param transport_options: Optional TransportOptions for configuring HTTP connections. + :param transport_options: Optional transport options for TLS, proxy, and headers. """ super().__init__(host, transport_options=transport_options) self.jwt_issuer = jwt_issuer diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index 258387ca..34a855c4 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -213,7 +213,7 @@ def with_access_token( :param host: API URL (e.g., "https://api.zitadel.example.com"). :param access_token: Personal Access Token for Bearer authentication. - :param transport_options: Optional transport options for TLS, proxy, headers. + :param transport_options: Optional transport options for TLS, proxy, and headers. :return: Configured Zitadel client instance. :see: https://zitadel.com/docs/guides/integrate/service-users/personal-access-token """ @@ -238,7 +238,7 @@ def with_client_credentials( :param host: API URL. :param client_id: OAuth2 client identifier. :param client_secret: OAuth2 client secret. - :param transport_options: Optional transport options for TLS, proxy, headers. + :param transport_options: Optional transport options for TLS, proxy, and headers. :return: Configured Zitadel client instance with token auto-refresh. :see: https://zitadel.com/docs/guides/integrate/service-users/client-credentials """ @@ -268,7 +268,7 @@ def with_private_key( :param host: API URL. :param key_file: Path to service account JSON or PEM key file. - :param transport_options: Optional transport options for TLS, proxy, headers. + :param transport_options: Optional transport options for TLS, proxy, and headers. :return: Configured Zitadel client instance using JWT assertion. :see: https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt """ From 46cf8a516522a9004fe1751065c3411d6a8feda6 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 09:53:12 +1100 Subject: [PATCH 27/36] Add docstring to defaults factory method\n\nDocument the defaults() class method on TransportOptions for\nconsistency with the other SDK implementations. --- zitadel_client/transport_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py index 2fd450d0..58122964 100644 --- a/zitadel_client/transport_options.py +++ b/zitadel_client/transport_options.py @@ -23,6 +23,7 @@ def __post_init__(self) -> None: @staticmethod def defaults() -> "TransportOptions": + """Returns a TransportOptions instance with all default values.""" return TransportOptions() def to_session_kwargs(self) -> dict: From 3b35cb7c7fd257d6277af28dfde1a31123b16928 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 12:40:28 +1100 Subject: [PATCH 28/36] Fix docstring inaccuracies in transport options\n\nClarify default_headers are sent to the origin server and fix\n_apply_transport_options param description to match its required\nsignature. --- zitadel_client/transport_options.py | 2 +- zitadel_client/zitadel.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py index 58122964..4494e9f6 100644 --- a/zitadel_client/transport_options.py +++ b/zitadel_client/transport_options.py @@ -7,7 +7,7 @@ class TransportOptions: """Immutable transport options for configuring HTTP connections. - :param default_headers: Additional HTTP headers to include in every request. + :param default_headers: Additional HTTP headers sent to the origin server with every request. :param ca_cert_path: Path to a custom CA certificate file for TLS verification. :param insecure: If True, disables TLS certificate verification. :param proxy_url: HTTP/HTTPS proxy URL to route requests through. diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index 34a855c4..dacca6ce 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -191,7 +191,7 @@ def _apply_transport_options( Apply transport options to the SDK configuration. :param config: The Configuration instance to modify. - :param transport_options: Optional transport options for TLS, proxy, and headers. + :param transport_options: Transport options for TLS, proxy, and headers. """ config.default_headers = dict(transport_options.default_headers) if transport_options.ca_cert_path: From 920c7ac190d13736bf2d31bf7d466530f8c8a42f Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 12:41:27 +1100 Subject: [PATCH 29/36] Standardize WireMock version to 3.12.1\n\nAlign transport options test with the version used in other tests. --- test/test_transport_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 3b994c0a..cb4283ef 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -42,7 +42,7 @@ def setup_class(cls) -> None: cls.network = Network().create() cls.wiremock = ( - DockerContainer("wiremock/wiremock:3.3.1") + DockerContainer("wiremock/wiremock:3.12.1") .with_network(cls.network) .with_network_aliases("wiremock") .with_exposed_ports(8080, 8443) From 1f68ae2c840d067cde4818738bfb855779124b49 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 13:08:24 +1100 Subject: [PATCH 30/36] Fix stale JWTAuthenticator references in docstrings\n\nReplace with the actual class name WebTokenAuthenticator. --- zitadel_client/auth/web_token_authenticator.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/zitadel_client/auth/web_token_authenticator.py b/zitadel_client/auth/web_token_authenticator.py index c677b323..8121f5ee 100644 --- a/zitadel_client/auth/web_token_authenticator.py +++ b/zitadel_client/auth/web_token_authenticator.py @@ -34,7 +34,7 @@ def __init__( transport_options: Optional[TransportOptions] = None, ): """ - Constructs a JWTAuthenticator. + Constructs a WebTokenAuthenticator. :param open_id: The base URL for the OAuth provider. :param auth_scopes: The scope(s) for the token request. @@ -144,22 +144,22 @@ def builder( host: str, user_id: str, private_key: str, transport_options: Optional[TransportOptions] = None ) -> "WebTokenAuthenticatorBuilder": """ - Returns a builder for constructing a JWTAuthenticator. + Returns a builder for constructing a WebTokenAuthenticator. :param host: The base URL for the OAuth provider. :param user_id: The user identifier, used as both the issuer and subject. :param private_key: The private key used to sign the JWT. :param transport_options: Optional transport options for TLS, proxy, and headers. - :return: A JWTAuthenticatorBuilder instance. + :return: A WebTokenAuthenticatorBuilder instance. """ return WebTokenAuthenticatorBuilder(host, user_id, user_id, host, private_key, transport_options=transport_options) class WebTokenAuthenticatorBuilder(OAuthAuthenticatorBuilder["WebTokenAuthenticatorBuilder"]): """ - Builder for JWTAuthenticator. + Builder for WebTokenAuthenticator. - Provides a fluent API for configuring and constructing a JWTAuthenticator instance. + Provides a fluent API for configuring and constructing a WebTokenAuthenticator instance. """ def __init__( @@ -173,7 +173,7 @@ def __init__( transport_options: Optional[TransportOptions] = None, ): """ - Initializes the JWTAuthenticatorBuilder with required parameters. + Initializes the WebTokenAuthenticatorBuilder with required parameters. :param host: The base URL for API endpoints. :param jwt_issuer: The issuer claim for the JWT. @@ -202,12 +202,12 @@ def token_lifetime_seconds(self, seconds: int) -> "WebTokenAuthenticatorBuilder" def build(self) -> WebTokenAuthenticator: """ - Builds and returns a new JWTAuthenticator instance using the configured parameters. + Builds and returns a new WebTokenAuthenticator instance using the configured parameters. This method inlines the JWT assertion generation logic and passes all required - configuration to the JWTAuthenticator constructor. + configuration to the WebTokenAuthenticator constructor. - :return: A new JWTAuthenticator instance. + :return: A new WebTokenAuthenticator instance. """ return WebTokenAuthenticator( open_id=self.open_id, From 6adf93fe3d1813b4f97a795fe67cd89aa9175e44 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 13:46:40 +1100 Subject: [PATCH 31/36] Use TransportOptions.defaults() consistently in factory methods --- zitadel_client/zitadel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zitadel_client/zitadel.py b/zitadel_client/zitadel.py index dacca6ce..f6d56ffc 100644 --- a/zitadel_client/zitadel.py +++ b/zitadel_client/zitadel.py @@ -217,7 +217,7 @@ def with_access_token( :return: Configured Zitadel client instance. :see: https://zitadel.com/docs/guides/integrate/service-users/personal-access-token """ - resolved = transport_options or TransportOptions() + resolved = transport_options or TransportOptions.defaults() def mutate_config(config: Configuration) -> None: Zitadel._apply_transport_options(config, resolved) @@ -242,7 +242,7 @@ def with_client_credentials( :return: Configured Zitadel client instance with token auto-refresh. :see: https://zitadel.com/docs/guides/integrate/service-users/client-credentials """ - resolved = transport_options or TransportOptions() + resolved = transport_options or TransportOptions.defaults() authenticator = ClientCredentialsAuthenticator.builder( host, @@ -272,7 +272,7 @@ def with_private_key( :return: Configured Zitadel client instance using JWT assertion. :see: https://zitadel.com/docs/guides/integrate/service-users/private-key-jwt """ - resolved = transport_options or TransportOptions() + resolved = transport_options or TransportOptions.defaults() authenticator = WebTokenAuthenticator.from_json( host, From 2058538e80c3466096c212355b092d9c105df20e Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 16:19:20 +1100 Subject: [PATCH 32/36] Replace programmatic WireMock stubs with static JSON mapping files\n\nWireMock auto-loads JSON files from /home/wiremock/mappings/ at startup,\nso there is no need to register stubs via the admin API in test setup. --- test/fixtures/mappings/oidc-discovery.json | 19 ++++++ test/fixtures/mappings/settings.json | 13 ++++ test/fixtures/mappings/token.json | 17 +++++ test/test_transport_options.py | 76 +--------------------- 4 files changed, 52 insertions(+), 73 deletions(-) create mode 100644 test/fixtures/mappings/oidc-discovery.json create mode 100644 test/fixtures/mappings/settings.json create mode 100644 test/fixtures/mappings/token.json diff --git a/test/fixtures/mappings/oidc-discovery.json b/test/fixtures/mappings/oidc-discovery.json new file mode 100644 index 00000000..3e2930a3 --- /dev/null +++ b/test/fixtures/mappings/oidc-discovery.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "url": "/.well-known/openid-configuration" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "issuer": "{{request.baseUrl}}", + "token_endpoint": "{{request.baseUrl}}/oauth/v2/token", + "authorization_endpoint": "{{request.baseUrl}}/oauth/v2/authorize", + "userinfo_endpoint": "{{request.baseUrl}}/oidc/v1/userinfo", + "jwks_uri": "{{request.baseUrl}}/oauth/v2/keys" + } + } +} diff --git a/test/fixtures/mappings/settings.json b/test/fixtures/mappings/settings.json new file mode 100644 index 00000000..b20c0103 --- /dev/null +++ b/test/fixtures/mappings/settings.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": {} + } +} diff --git a/test/fixtures/mappings/token.json b/test/fixtures/mappings/token.json new file mode 100644 index 00000000..b4227d3f --- /dev/null +++ b/test/fixtures/mappings/token.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "POST", + "url": "/oauth/v2/token" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "access_token": "test-token-12345", + "token_type": "Bearer", + "expires_in": 3600 + } + } +} diff --git a/test/test_transport_options.py b/test/test_transport_options.py index cb4283ef..96897ed4 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -47,6 +47,9 @@ def setup_class(cls) -> None: .with_network_aliases("wiremock") .with_exposed_ports(8080, 8443) .with_volume_mapping(keystore_path, "/home/wiremock/keystore.p12", mode="ro") + .with_volume_mapping( + os.path.join(FIXTURES_DIR, "mappings"), "/home/wiremock/mappings", mode="ro" + ) .with_command( "--https-port 8443" " --https-keystore /home/wiremock/keystore.p12" @@ -73,79 +76,6 @@ def setup_class(cls) -> None: _wait_for_wiremock(cls.host, cls.http_port) - oidc_stub = json.dumps( - { - "request": {"method": "GET", "url": "/.well-known/openid-configuration"}, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "body": ( - '{"issuer":"{{request.baseUrl}}",' - '"token_endpoint":"{{request.baseUrl}}/oauth/v2/token",' - '"authorization_endpoint":"{{request.baseUrl}}/oauth/v2/authorize",' - '"userinfo_endpoint":"{{request.baseUrl}}/oidc/v1/userinfo",' - '"jwks_uri":"{{request.baseUrl}}/oauth/v2/keys"}' - ), - }, - } - ).encode() - - req = urllib.request.Request( - f"http://{cls.host}:{cls.http_port}/__admin/mappings", - data=oidc_stub, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req) as resp: # noqa: S310 - assert resp.status == 201 - - token_stub = json.dumps( - { - "request": {"method": "POST", "url": "/oauth/v2/token"}, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "jsonBody": { - "access_token": "test-token-12345", - "token_type": "Bearer", - "expires_in": 3600, - }, - }, - } - ).encode() - - req = urllib.request.Request( - f"http://{cls.host}:{cls.http_port}/__admin/mappings", - data=token_stub, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req) as resp: # noqa: S310 - assert resp.status == 201 - - settings_stub = json.dumps( - { - "request": { - "method": "POST", - "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings", - }, - "response": { - "status": 200, - "headers": {"Content-Type": "application/json"}, - "jsonBody": {}, - }, - } - ).encode() - - req = urllib.request.Request( - f"http://{cls.host}:{cls.http_port}/__admin/mappings", - data=settings_stub, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req) as resp: # noqa: S310 - assert resp.status == 201 - @classmethod def teardown_class(cls) -> None: if cls.proxy is not None: From 4ae332914256ae4def0f8682c1f705873534af68 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 19:43:00 +1100 Subject: [PATCH 33/36] Standardize TransportOptions docstrings for cross-SDK consistency --- zitadel_client/transport_options.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zitadel_client/transport_options.py b/zitadel_client/transport_options.py index 4494e9f6..623cfb01 100644 --- a/zitadel_client/transport_options.py +++ b/zitadel_client/transport_options.py @@ -7,10 +7,10 @@ class TransportOptions: """Immutable transport options for configuring HTTP connections. - :param default_headers: Additional HTTP headers sent to the origin server with every request. + :param default_headers: Default HTTP headers sent to the origin server with every request. :param ca_cert_path: Path to a custom CA certificate file for TLS verification. - :param insecure: If True, disables TLS certificate verification. - :param proxy_url: HTTP/HTTPS proxy URL to route requests through. + :param insecure: Whether to disable TLS certificate verification. + :param proxy_url: Proxy URL for HTTP connections. """ default_headers: Mapping[str, str] = field(default_factory=dict) From de2344cd29573f1e8e6b2614949719d7eff41d6e Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Mon, 9 Mar 2026 23:53:30 +1100 Subject: [PATCH 34/36] Restructure tests: split TransportOptions unit tests from Zitadel integration tests --- test/fixtures/mappings/settings.json | 5 +- test/test_transport_options.py | 164 +++++---------------------- test/test_zitadel.py | 134 +++++++++++++++++++++- 3 files changed, 162 insertions(+), 141 deletions(-) diff --git a/test/fixtures/mappings/settings.json b/test/fixtures/mappings/settings.json index b20c0103..524a367a 100644 --- a/test/fixtures/mappings/settings.json +++ b/test/fixtures/mappings/settings.json @@ -8,6 +8,9 @@ "headers": { "Content-Type": "application/json" }, - "jsonBody": {} + "jsonBody": { + "defaultLanguage": "{{request.scheme}}", + "defaultOrgId": "{{request.headers.X-Custom-Header}}" + } } } diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 96897ed4..03f3dc15 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -1,148 +1,40 @@ -import json -import os import unittest -import urllib.request -from typing import Optional - -from testcontainers.core.container import DockerContainer -from testcontainers.core.network import Network -from testcontainers.core.wait_strategies import PortWaitStrategy -from testcontainers.core.waiting_utils import wait_container_is_ready from zitadel_client.transport_options import TransportOptions -from zitadel_client.zitadel import Zitadel - -FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") - - -@wait_container_is_ready() -def _wait_for_wiremock(host: str, port: str) -> None: - url = f"http://{host}:{port}/__admin/mappings" - with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 - if resp.status != 200: - raise ConnectionError(f"WireMock not ready: {resp.status}") class TransportOptionsTest(unittest.TestCase): - host: Optional[str] = None - http_port: Optional[str] = None - https_port: Optional[str] = None - proxy_port: Optional[str] = None - ca_cert_path: Optional[str] = None - wiremock: DockerContainer = None - proxy: DockerContainer = None - network: Network = None - - @classmethod - def setup_class(cls) -> None: - cls.ca_cert_path = os.path.join(FIXTURES_DIR, "ca.pem") - keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12") - squid_conf = os.path.join(FIXTURES_DIR, "squid.conf") - - cls.network = Network().create() - - cls.wiremock = ( - DockerContainer("wiremock/wiremock:3.12.1") - .with_network(cls.network) - .with_network_aliases("wiremock") - .with_exposed_ports(8080, 8443) - .with_volume_mapping(keystore_path, "/home/wiremock/keystore.p12", mode="ro") - .with_volume_mapping( - os.path.join(FIXTURES_DIR, "mappings"), "/home/wiremock/mappings", mode="ro" - ) - .with_command( - "--https-port 8443" - " --https-keystore /home/wiremock/keystore.p12" - " --keystore-password password" - " --keystore-type PKCS12" - " --global-response-templating" - ) - ) - cls.wiremock.start() - - cls.proxy = ( - DockerContainer("ubuntu/squid:6.10-24.10_beta") - .with_network(cls.network) - .with_exposed_ports(3128) - .with_volume_mapping(squid_conf, "/etc/squid/squid.conf", mode="ro") - .waiting_for(PortWaitStrategy(3128)) - ) - cls.proxy.start() - cls.host = cls.wiremock.get_container_host_ip() - cls.http_port = cls.wiremock.get_exposed_port(8080) - cls.https_port = cls.wiremock.get_exposed_port(8443) - cls.proxy_port = cls.proxy.get_exposed_port(3128) + def test_defaults_returns_empty(self) -> None: + self.assertEqual({}, TransportOptions.defaults().to_session_kwargs()) - _wait_for_wiremock(cls.host, cls.http_port) + def test_insecure_sets_verify_false(self) -> None: + opts = TransportOptions(insecure=True) + self.assertEqual({"verify": False}, opts.to_session_kwargs()) - @classmethod - def teardown_class(cls) -> None: - if cls.proxy is not None: - cls.proxy.stop() - if cls.wiremock is not None: - cls.wiremock.stop() - if cls.network is not None: - cls.network.remove() - - def test_custom_ca_cert(self) -> None: - zitadel = Zitadel.with_client_credentials( - f"https://{self.host}:{self.https_port}", - "dummy-client", - "dummy-secret", - transport_options=TransportOptions(ca_cert_path=self.ca_cert_path), - ) - self.assertIsNotNone(zitadel) - - def test_insecure_mode(self) -> None: - zitadel = Zitadel.with_client_credentials( - f"https://{self.host}:{self.https_port}", - "dummy-client", - "dummy-secret", - transport_options=TransportOptions(insecure=True), - ) - self.assertIsNotNone(zitadel) - - def test_default_headers(self) -> None: - zitadel = Zitadel.with_client_credentials( - f"http://{self.host}:{self.http_port}", - "dummy-client", - "dummy-secret", - transport_options=TransportOptions(default_headers={"X-Custom-Header": "test-value"}), - ) - self.assertIsNotNone(zitadel) - - zitadel.settings.get_general_settings({}) - - verify_body = json.dumps( - { - "url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings", - "headers": {"X-Custom-Header": {"equalTo": "test-value"}}, - } - ).encode() - req = urllib.request.Request( - f"http://{self.host}:{self.http_port}/__admin/requests/count", - data=verify_body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req) as resp: # noqa: S310 - result = json.loads(resp.read().decode()) - self.assertGreaterEqual(result["count"], 1, "Custom header should be present on API call") + def test_ca_cert_path_sets_verify(self) -> None: + opts = TransportOptions(ca_cert_path="/path/to/ca.pem") + self.assertEqual({"verify": "/path/to/ca.pem"}, opts.to_session_kwargs()) - def test_proxy_url(self) -> None: - zitadel = Zitadel.with_access_token( - "http://wiremock:8080", - "test-token", - transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.proxy_port}"), + def test_proxy_url_sets_proxies(self) -> None: + opts = TransportOptions(proxy_url="http://proxy:3128") + self.assertEqual( + {"proxies": {"http": "http://proxy:3128", "https": "http://proxy:3128"}}, + opts.to_session_kwargs(), ) - self.assertIsNotNone(zitadel) - zitadel.settings.get_general_settings({}) - def test_no_ca_cert_fails(self) -> None: - with self.assertRaises(Exception): # noqa: B017 - Zitadel.with_client_credentials( - f"https://{self.host}:{self.https_port}", - "dummy-client", - "dummy-secret", - ) + def test_insecure_takes_precedence_over_ca_cert(self) -> None: + opts = TransportOptions(insecure=True, ca_cert_path="/path/to/ca.pem") + self.assertEqual({"verify": False}, opts.to_session_kwargs()) + + def test_immutability(self) -> None: + opts = TransportOptions.defaults() + with self.assertRaises(AttributeError): + opts.insecure = True # type: ignore[misc] + + def test_defaults_factory(self) -> None: + opts = TransportOptions.defaults() + self.assertEqual({}, dict(opts.default_headers)) + self.assertIsNone(opts.ca_cert_path) + self.assertFalse(opts.insecure) + self.assertIsNone(opts.proxy_url) diff --git a/test/test_zitadel.py b/test/test_zitadel.py index faecaafa..39094f46 100644 --- a/test/test_zitadel.py +++ b/test/test_zitadel.py @@ -1,17 +1,32 @@ import importlib import inspect +import os import pkgutil import unittest +import urllib.request +from typing import Optional + +from testcontainers.core.container import DockerContainer +from testcontainers.core.network import Network +from testcontainers.core.wait_strategies import PortWaitStrategy +from testcontainers.core.waiting_utils import wait_container_is_ready from zitadel_client.auth.no_auth_authenticator import NoAuthAuthenticator +from zitadel_client.transport_options import TransportOptions from zitadel_client.zitadel import Zitadel +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + + +@wait_container_is_ready() +def _wait_for_wiremock(host: str, port: str) -> None: + url = f"http://{host}:{port}/__admin/mappings" + with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 + if resp.status != 200: + raise ConnectionError(f"WireMock not ready: {resp.status}") + class ZitadelServicesTest(unittest.TestCase): - """ - Test to verify that all API service classes defined in the "zitadel_client.api" namespace - are registered as attributes in the Zitadel class. - """ def test_services_dynamic(self) -> None: expected = set() @@ -30,3 +45,114 @@ def test_services_dynamic(self) -> None: and getattr(zitadel, attr).__class__.__module__.startswith("zitadel_client.api") } self.assertEqual(expected, actual) + + +class ZitadelTransportTest(unittest.TestCase): + host: Optional[str] = None + http_port: Optional[str] = None + https_port: Optional[str] = None + proxy_port: Optional[str] = None + ca_cert_path: Optional[str] = None + wiremock: DockerContainer = None + proxy: DockerContainer = None + network: Network = None + + @classmethod + def setup_class(cls) -> None: + cls.ca_cert_path = os.path.join(FIXTURES_DIR, "ca.pem") + keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12") + squid_conf = os.path.join(FIXTURES_DIR, "squid.conf") + + cls.network = Network().create() + + cls.wiremock = ( + DockerContainer("wiremock/wiremock:3.12.1") + .with_network(cls.network) + .with_network_aliases("wiremock") + .with_exposed_ports(8080, 8443) + .with_volume_mapping(keystore_path, "/home/wiremock/keystore.p12", mode="ro") + .with_volume_mapping( + os.path.join(FIXTURES_DIR, "mappings"), "/home/wiremock/mappings", mode="ro" + ) + .with_command( + "--https-port 8443" + " --https-keystore /home/wiremock/keystore.p12" + " --keystore-password password" + " --keystore-type PKCS12" + " --global-response-templating" + ) + ) + cls.wiremock.start() + + cls.proxy = ( + DockerContainer("ubuntu/squid:6.10-24.10_beta") + .with_network(cls.network) + .with_exposed_ports(3128) + .with_volume_mapping(squid_conf, "/etc/squid/squid.conf", mode="ro") + .waiting_for(PortWaitStrategy(3128)) + ) + cls.proxy.start() + + cls.host = cls.wiremock.get_container_host_ip() + cls.http_port = cls.wiremock.get_exposed_port(8080) + cls.https_port = cls.wiremock.get_exposed_port(8443) + cls.proxy_port = cls.proxy.get_exposed_port(3128) + + _wait_for_wiremock(cls.host, cls.http_port) + + @classmethod + def teardown_class(cls) -> None: + if cls.proxy is not None: + cls.proxy.stop() + if cls.wiremock is not None: + cls.wiremock.stop() + if cls.network is not None: + cls.network.remove() + + def test_custom_ca_cert(self) -> None: + zitadel = Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + transport_options=TransportOptions(ca_cert_path=self.ca_cert_path), + ) + response = zitadel.settings.get_general_settings({}) + self.assertEqual("https", response.default_language) + + def test_insecure_mode(self) -> None: + zitadel = Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + transport_options=TransportOptions(insecure=True), + ) + response = zitadel.settings.get_general_settings({}) + self.assertEqual("https", response.default_language) + + def test_default_headers(self) -> None: + zitadel = Zitadel.with_client_credentials( + f"http://{self.host}:{self.http_port}", + "dummy-client", + "dummy-secret", + transport_options=TransportOptions(default_headers={"X-Custom-Header": "test-value"}), + ) + response = zitadel.settings.get_general_settings({}) + self.assertEqual("http", response.default_language) + self.assertEqual("test-value", response.default_org_id) + + def test_proxy_url(self) -> None: + zitadel = Zitadel.with_access_token( + "http://wiremock:8080", + "test-token", + transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.proxy_port}"), + ) + response = zitadel.settings.get_general_settings({}) + self.assertEqual("http", response.default_language) + + def test_no_ca_cert_fails(self) -> None: + with self.assertRaises(Exception): # noqa: B017 + Zitadel.with_client_credentials( + f"https://{self.host}:{self.https_port}", + "dummy-client", + "dummy-secret", + ) From cdaef2e04eff9f13c094818bb40c03a753652fd5 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Tue, 10 Mar 2026 09:06:16 +1100 Subject: [PATCH 35/36] Remove unused urllib import in open_id.py --- zitadel_client/auth/open_id.py | 1 - 1 file changed, 1 deletion(-) diff --git a/zitadel_client/auth/open_id.py b/zitadel_client/auth/open_id.py index 30b18ce2..e2be4505 100644 --- a/zitadel_client/auth/open_id.py +++ b/zitadel_client/auth/open_id.py @@ -1,6 +1,5 @@ import json import ssl -import urllib import urllib.error import urllib.request from typing import Optional From a80b4227a18332de5220224ee1f0664c678fde54 Mon Sep 17 00:00:00 2001 From: Mridang Agarwalla Date: Tue, 10 Mar 2026 09:31:39 +1100 Subject: [PATCH 36/36] Harden insecure precedence test with nonexistent CA cert path --- test/test_transport_options.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_transport_options.py b/test/test_transport_options.py index 03f3dc15..92601208 100644 --- a/test/test_transport_options.py +++ b/test/test_transport_options.py @@ -4,7 +4,6 @@ class TransportOptionsTest(unittest.TestCase): - def test_defaults_returns_empty(self) -> None: self.assertEqual({}, TransportOptions.defaults().to_session_kwargs()) @@ -24,7 +23,7 @@ def test_proxy_url_sets_proxies(self) -> None: ) def test_insecure_takes_precedence_over_ca_cert(self) -> None: - opts = TransportOptions(insecure=True, ca_cert_path="/path/to/ca.pem") + opts = TransportOptions(insecure=True, ca_cert_path="/nonexistent/ca.pem") self.assertEqual({"verify": False}, opts.to_session_kwargs()) def test_immutability(self) -> None: