diff --git a/pyproject.toml b/pyproject.toml index 94270634..9f45a831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.58.0" +version = "0.58.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/core/_tenant.py b/src/sap_cloud_sdk/core/_tenant.py index 1bb4b1ec..4bf3dbb0 100644 --- a/src/sap_cloud_sdk/core/_tenant.py +++ b/src/sap_cloud_sdk/core/_tenant.py @@ -1,4 +1,5 @@ import re +from urllib.parse import urlparse, urlunparse _SUBDOMAIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$") @@ -18,3 +19,34 @@ def _validate_tenant_subdomain(tenant_subdomain: str | None) -> None: return if not _SUBDOMAIN_RE.fullmatch(tenant_subdomain): raise ValueError(f"Invalid tenant_subdomain: {tenant_subdomain!r}") + + +def _derive_tenant_token_url( + token_url: str, + identityzone: str, + tenant_subdomain: str, +) -> str: + """Return *token_url* with the first hostname label replaced by *tenant_subdomain*. + + Only the leading DNS label of the hostname is replaced when it equals + *identityzone*. All other URL components (scheme, port, path, query, + fragment) are preserved verbatim. If the first label does not match + *identityzone*, the original URL is returned unchanged. + + Args: + token_url: The configured OAuth2 token endpoint URL. + identityzone: Provider identity zone label from the service binding. + tenant_subdomain: Validated single-label tenant identifier. + + Returns: + The derived token URL with only the first hostname label swapped. + """ + parsed = urlparse(token_url) + host = parsed.hostname or "" + first_label, sep, rest = host.partition(".") + if sep and first_label == identityzone: + new_netloc = f"{tenant_subdomain}.{rest}" + if parsed.port is not None: + new_netloc = f"{new_netloc}:{parsed.port}" + return urlunparse(parsed._replace(netloc=new_netloc)) + return token_url diff --git a/src/sap_cloud_sdk/core/protocol/http/models.py b/src/sap_cloud_sdk/core/protocol/http/models.py index e8e68014..f5e0e09f 100644 --- a/src/sap_cloud_sdk/core/protocol/http/models.py +++ b/src/sap_cloud_sdk/core/protocol/http/models.py @@ -13,7 +13,10 @@ from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session -from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import ( + _validate_tenant_subdomain, + _derive_tenant_token_url, +) logger = logging.getLogger(__name__) @@ -112,7 +115,9 @@ def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: and token_url is not None ): _validate_tenant_subdomain(tenant_subdomain) - token_url = str(token_url).replace(str(identityzone), tenant_subdomain) + token_url = _derive_tenant_token_url( + str(token_url), str(identityzone), tenant_subdomain + ) client = BackendApplicationClient(client_id=str(self._config.client_id)) oauth = OAuth2Session(client=client) diff --git a/tests/core/unit/test_http_client.py b/tests/core/unit/test_http_client.py index 46c7fd4d..83d4c997 100644 --- a/tests/core/unit/test_http_client.py +++ b/tests/core/unit/test_http_client.py @@ -284,3 +284,25 @@ def test_valid_subdomain_replaces_identityzone_in_token_url(self): provider._fetch_token("tenant-123") call_kwargs = mock_session.fetch_token.call_args[1] assert call_kwargs["token_url"] == "https://tenant-123.authentication.region/oauth/token" + + def test_identityzone_in_path_is_not_replaced(self): + """str.replace would corrupt the URL if identityzone appears in the path too.""" + cfg = MagicMock() + # token_url whose path also contains the identityzone value + cfg.token_url = "https://provider-zone.authentication.region/provider-zone/token" + cfg.identityzone = "provider-zone" + cfg.client_id = "cid" + cfg.client_secret = "csecret" + factory = MagicMock(return_value=cfg) + factory.has_changed = MagicMock(return_value=False) + provider = XsuaaAuthProvider(factory) + + with patch("sap_cloud_sdk.core.protocol.http.models.OAuth2Session") as mock_oauth_cls: + mock_session = MagicMock() + mock_oauth_cls.return_value = mock_session + mock_session.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} + provider._fetch_token("tenant-abc") + + call_kwargs = mock_session.fetch_token.call_args[1] + # Only the first hostname label must be replaced; path segment untouched + assert call_kwargs["token_url"] == "https://tenant-abc.authentication.region/provider-zone/token" diff --git a/tests/core/unit/test_tenant.py b/tests/core/unit/test_tenant.py index b1d259d4..8839b829 100644 --- a/tests/core/unit/test_tenant.py +++ b/tests/core/unit/test_tenant.py @@ -2,7 +2,7 @@ import pytest -from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain, _derive_tenant_token_url class TestValidateTenantSubdomain: @@ -36,3 +36,42 @@ def test_invalid_subdomains_raise_value_error(self, invalid, description): def test_none_is_a_no_op(self): _validate_tenant_subdomain(None) # must not raise + + +class TestDeriveTenantTokenUrl: + BASE = "https://provider-zone.authentication.eu10.hana.ondemand.com/oauth/token" + IZ = "provider-zone" + + def test_replaces_first_label_only(self): + result = _derive_tenant_token_url(self.BASE, self.IZ, "tenant-123") + assert result == "https://tenant-123.authentication.eu10.hana.ondemand.com/oauth/token" + + def test_scheme_preserved(self): + result = _derive_tenant_token_url(self.BASE, self.IZ, "tenant-123") + assert result.startswith("https://") + + def test_path_preserved(self): + result = _derive_tenant_token_url(self.BASE, self.IZ, "tenant-123") + assert result.endswith("/oauth/token") + + def test_identityzone_in_path_not_replaced(self): + # Even if identityzone value appears in the path, it must not be touched + url = f"https://provider-zone.auth.region/{self.IZ}/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-abc") + assert result == f"https://tenant-abc.auth.region/{self.IZ}/token" + + def test_port_preserved_when_present(self): + url = "https://provider-zone.authentication.region:8443/oauth/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-123") + assert result == "https://tenant-123.authentication.region:8443/oauth/token" + + def test_no_match_returns_original_url(self): + url = "https://other-zone.authentication.region/oauth/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-123") + assert result == url + + def test_single_label_hostname_returns_original(self): + # token_url with no dots in host — nothing to replace + url = "https://provider-zone/oauth/token" + result = _derive_tenant_token_url(url, self.IZ, "tenant-123") + assert result == url diff --git a/uv.lock b/uv.lock index f392fca5..f7db0eff 100644 --- a/uv.lock +++ b/uv.lock @@ -4363,7 +4363,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.58.0" +version = "0.58.1" source = { editable = "." } dependencies = [ { name = "cryptography" },