Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
32 changes: 32 additions & 0 deletions src/sap_cloud_sdk/core/_tenant.py
Original file line number Diff line number Diff line change
@@ -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])?$")
Expand All @@ -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
9 changes: 7 additions & 2 deletions src/sap_cloud_sdk/core/protocol/http/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tests/core/unit/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
41 changes: 40 additions & 1 deletion tests/core/unit/test_tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading