diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliers.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliers.java
index 869176d6b..ffe952a72 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliers.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliers.java
@@ -195,6 +195,7 @@ public OAuth2Options getOAuth2Options()
.peek(format -> builder.withTokenRetrievalParameter("token_format", format));
}
attachClientKeyStore(builder);
+ getCredential(URI.class, "btp-tenant-api").peek(builder::withBtpTenantApiBaseUri);
return builder.build();
}
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IasTenantHostResolver.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IasTenantHostResolver.java
new file mode 100644
index 000000000..ada5d11b4
--- /dev/null
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IasTenantHostResolver.java
@@ -0,0 +1,90 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.json.JSONObject;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+
+import lombok.extern.slf4j.Slf4j;
+import lombok.val;
+
+/**
+ * Resolves the IAS tenant host for a given tenant ID by querying the BTP tenant API.
+ *
+ * The endpoint returns OIDC metadata including a {@code token_endpoint}. The IAS host subdomain is extracted from the
+ * first host label of that URL, which identifies the IAS tenant.
+ */
+@Slf4j
+class IasTenantHostResolver
+{
+ static final IasTenantHostResolver DEFAULT_INSTANCE = new IasTenantHostResolver();
+ private static final String TENANT_INFO_ENDPOINT_TEMPLATE = "/sap/rest/tenantLoginInfo?id=%s";
+
+ private final CloseableHttpClient httpClient;
+
+ private IasTenantHostResolver()
+ {
+ this.httpClient = HttpClients.createDefault();
+ }
+
+ /**
+ * Queries {@code btpTenantApiUri} with {@code ?id=} and extracts the IAS tenant subdomain from the
+ * {@code token_endpoint} field in the JSON response.
+ *
+ * @param btpTenantApiUri
+ * The full URL of the BTP tenant login-info endpoint.
+ * @param tenantId
+ * The tenant ID (app_tid/subaccount ID) to look up.
+ * @return The subdomain extracted from the {@code token_endpoint} host.
+ * @throws DestinationAccessException
+ * if the HTTP request fails, the response is not 200, or the subdomain cannot be parsed from the
+ * response.
+ */
+ @Nonnull
+ String resolve( @Nonnull final URI btpTenantApiUri, @Nonnull final String tenantId )
+ {
+ val url = btpTenantApiUri.resolve(TENANT_INFO_ENDPOINT_TEMPLATE.formatted(tenantId));
+ log.debug("Dynamically resolving IAS tenant host for tenant '{}' via {}.", tenantId, url);
+ val req = new HttpGet(url);
+ try {
+ return httpClient.execute(req, response -> {
+ if( response.getCode() != HttpStatus.SC_OK ) {
+ throw new DestinationAccessException(
+ "Failed to query BTP tenant API: Server returned status code %d for GET request to '%s'."
+ .formatted(response.getCode(), url));
+ }
+ val body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+ return extractSubdomainFromTokenEndpoint(body);
+ });
+ }
+ catch( IOException e ) {
+ throw new DestinationAccessException("Failed to query BTP tenant API: " + e.getMessage(), e);
+ }
+ }
+
+ @Nonnull
+ static String extractSubdomainFromTokenEndpoint( @Nonnull final String responseBody )
+ {
+ try {
+ final String tokenEndpoint = new JSONObject(responseBody).getString("token_endpoint");
+ final String host = URI.create(tokenEndpoint).getHost();
+ return host.substring(0, host.indexOf('.'));
+ }
+ catch( final Exception e ) {
+ throw new DestinationAccessException(
+ "Failed to extract IAS tenant host from the BTP tenant API response. The response did not conform to to the expected format: "
+ + responseBody,
+ e);
+ }
+ }
+}
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Options.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Options.java
index 079813cd8..df4f0602c 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Options.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Options.java
@@ -1,5 +1,6 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;
+import java.net.URI;
import java.security.KeyStore;
import java.time.Duration;
import java.util.HashMap;
@@ -50,7 +51,7 @@ public final class OAuth2Options
* for the target system connection.
*/
public static final OAuth2Options DEFAULT =
- new OAuth2Options(false, Map.of(), DEFAULT_TIMEOUT, null, DEFAULT_TOKEN_CACHE_PARAMETERS);
+ new OAuth2Options(false, Map.of(), DEFAULT_TIMEOUT, null, DEFAULT_TOKEN_CACHE_PARAMETERS, null);
private final boolean skipTokenRetrieval;
@Nonnull
@@ -80,6 +81,15 @@ public final class OAuth2Options
@Getter
private final TokenCacheParameters tokenCacheParameters;
+ /**
+ * Base URI of the BTP tenant API endpoint from the IAS service binding (the {@code btp-tenant-api} credential).
+ * When present, {@link OAuth2Service} uses it to derive a per-tenant token URL instead of the static {@code url}.
+ * Package-private; not part of the public API.
+ */
+ @Nullable
+ @Getter( AccessLevel.PACKAGE )
+ private final URI btpTenantApiBaseUri;
+
/**
* Indicates whether to skip the OAuth2 token flow.
*
@@ -124,6 +134,8 @@ public static class Builder
private KeyStore clientKeyStore;
private TimeLimiterConfiguration timeLimiter = DEFAULT_TIMEOUT;
private TokenCacheParameters tokenCacheParameters = DEFAULT_TOKEN_CACHE_PARAMETERS;
+ @Nullable
+ private URI btpTenantApiBaseUri;
/**
* Indicates whether to skip the OAuth2 token flow.
@@ -216,6 +228,13 @@ public Builder withTokenCacheParameters( @Nonnull final TokenCacheParameters tok
return this;
}
+ @Nonnull
+ Builder withBtpTenantApiBaseUri( @Nullable final URI btpTenantApiBaseUri )
+ {
+ this.btpTenantApiBaseUri = btpTenantApiBaseUri;
+ return this;
+ }
+
/**
* Creates a new {@link OAuth2Options} instance.
*
@@ -237,7 +256,8 @@ public OAuth2Options build()
new HashMap<>(additionalTokenRetrievalParameters),
timeLimiter,
clientKeyStore,
- tokenCacheParameters);
+ tokenCacheParameters,
+ btpTenantApiBaseUri);
}
}
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
index a07af3fa2..72313a99a 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
@@ -45,6 +45,7 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
+import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
/**
@@ -91,6 +92,11 @@ class OAuth2Service
private final ResilienceConfiguration resilienceConfiguration;
@Nonnull
private final TokenCacheParameters tokenCacheParameters;
+ @Nullable
+ private final URI btpTenantApiUri;
+ @Nonnull
+ @Setter( AccessLevel.PACKAGE )
+ private IasTenantHostResolver iasTenantHostResolver = IasTenantHostResolver.DEFAULT_INSTANCE;
// package-private for testing
@Nonnull
@@ -249,16 +255,16 @@ private String getTenantSubdomainOrNull( @Nullable final Tenant tenant )
return null;
}
- if( !(tenant instanceof TenantWithSubdomain tenantWithSubdomain) ) {
- final String msg = "Unable to get subdomain of tenant '%s' because the instance is not an instance of %s.";
- throw new DestinationAccessException(msg.formatted(tenant, TenantWithSubdomain.class.getSimpleName()));
+ if( tenant instanceof TenantWithSubdomain tenantWithSubdomain && tenantWithSubdomain.getSubdomain() != null ) {
+ return tenantWithSubdomain.getSubdomain();
}
- final var subdomain = tenantWithSubdomain.getSubdomain();
- if( subdomain == null ) {
+ log.debug("IAS tenant host is unknown for tenant {}. Performing IAS host lookup.", tenant.getTenantId());
+ if( btpTenantApiUri == null ) {
throw new DestinationAccessException(
- "The given tenant '%s' does not have a subdomain defined.".formatted(tenant));
+ "Failed to dynamically resolve IAS tenant host: The BTP API URL is not given. "
+ + "Ensure your IAS service binding contains the BTP tenant API URL in the property 'btp-tenant-api'.");
}
- return subdomain;
+ return iasTenantHostResolver.resolve(btpTenantApiUri, tenant.getTenantId());
}
@Nullable
@@ -341,6 +347,8 @@ static class Builder
private final Map additionalParameters = new HashMap<>();
private ResilienceConfiguration.TimeLimiterConfiguration timeLimiter = OAuth2Options.DEFAULT_TIMEOUT;
private TokenCacheParameters tokenCacheParameters = OAuth2Options.DEFAULT_TOKEN_CACHE_PARAMETERS;
+ @Nullable
+ private URI btpTenantApiUri;
@Nonnull
Builder withTokenUri( @Nonnull final String tokenUri )
@@ -425,6 +433,13 @@ Builder withTokenCacheParameters( @Nonnull final TokenCacheParameters tokenCache
return this;
}
+ @Nonnull
+ Builder withBtpTenantApiUri( @Nullable final URI btpTenantApiBaseUri )
+ {
+ this.btpTenantApiUri = btpTenantApiBaseUri;
+ return this;
+ }
+
@Nonnull
OAuth2Service build()
{
@@ -455,7 +470,8 @@ OAuth2Service build()
tenantPropagationStrategy,
additionalParameters,
resilienceConfig,
- tokenCacheParameters);
+ tokenCacheParameters,
+ btpTenantApiUri);
}
}
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBindingDestinationLoader.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBindingDestinationLoader.java
index bb5b88bcd..1dc871c69 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBindingDestinationLoader.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBindingDestinationLoader.java
@@ -327,6 +327,7 @@ DestinationHeaderProvider createHeaderProvider(
.withAdditionalParameters(oAuth2Options.getAdditionalTokenRetrievalParameters())
.withTimeLimiter(oAuth2Options.getTimeLimiter())
.withTokenCacheParameters(oAuth2Options.getTokenCacheParameters())
+ .withBtpTenantApiUri(oAuth2Options.getBtpTenantApiBaseUri())
.build();
return new OAuth2HeaderProvider(oAuth2Service, authHeader);
}
diff --git a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliersTest.java b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliersTest.java
index f7be0e7a8..0040c67f1 100644
--- a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliersTest.java
+++ b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliersTest.java
@@ -456,6 +456,7 @@ class IdentityAuthenticationTest
ServiceIdentifier.IDENTITY_AUTHENTICATION,
entry("app_tid", PROVIDER_TENANT_ID),
entry("url", PROVIDER_URL),
+ entry("btp-tenant-api", "https://api.authentication.eu12.hana.ondemand.com"),
entry("credential-type", "X509_GENERATED"),
entry("clientid", "ias-client-id"),
entry("key", getKey()),
@@ -482,6 +483,46 @@ void testNoParameters()
.containsValue(PROVIDER_TENANT_ID);
}
+ @Test
+ void testBtpTenantApiIsLoadedIntoOAuth2Options()
+ {
+ final ServiceBindingDestinationOptions options =
+ ServiceBindingDestinationOptions.forService(BINDING).build();
+
+ final OAuth2PropertySupplier sut = IDENTITY_AUTHENTICATION.resolve(options);
+
+ assertThat(sut).isNotNull();
+
+ final OAuth2Options oAuth2Options = sut.getOAuth2Options();
+ assertThat(oAuth2Options.getBtpTenantApiBaseUri())
+ .isNotNull()
+ .hasToString("https://api.authentication.eu12.hana.ondemand.com");
+ }
+
+ @Test
+ void testBtpTenantApiIsAbsentWhenNotInBinding()
+ {
+ final ServiceBinding bindingWithoutBtpTenantApi =
+ bindingWithCredentials(
+ ServiceIdentifier.IDENTITY_AUTHENTICATION,
+ entry("app_tid", PROVIDER_TENANT_ID),
+ entry("url", PROVIDER_URL),
+ entry("credential-type", "X509_GENERATED"),
+ entry("clientid", "ias-client-id"),
+ entry("key", getKey()),
+ entry("certificate", getCert()));
+
+ final ServiceBindingDestinationOptions options =
+ ServiceBindingDestinationOptions.forService(bindingWithoutBtpTenantApi).build();
+
+ final OAuth2PropertySupplier sut = IDENTITY_AUTHENTICATION.resolve(options);
+
+ assertThat(sut).isNotNull();
+
+ final OAuth2Options oAuth2Options = sut.getOAuth2Options();
+ assertThat(oAuth2Options.getBtpTenantApiBaseUri()).isNull();
+ }
+
@Test
void testTargetUri()
{
diff --git a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/IasTenantHostResolverTest.java b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/IasTenantHostResolverTest.java
new file mode 100644
index 000000000..474630cdc
--- /dev/null
+++ b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/IasTenantHostResolverTest.java
@@ -0,0 +1,98 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.serverError;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+
+import org.junit.jupiter.api.Test;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+
+@WireMockTest
+class IasTenantHostResolverTest
+{
+ private static final String TENANT_ID = "c992fd47-9eb4-4a7c-b932-9a8479d6c69f";
+
+ private static final String RESPONSE = """
+ {
+ "status": "ACTIVE",
+ "zoneId": "c992fd47-9eb4-4a7c-b932-9a8479d6c69f",
+ "subaccountId": "c992fd47-9eb4-4a7c-b932-9a8479d6c69f",
+ "subdomain": "btp-subaccount-subdomain",
+ "authorization_endpoint": "https://test.accounts400.ondemand.com/oauth2/authorize",
+ "token_endpoint": "https://test.accounts400.ondemand.com/oauth2/token",
+ "userinfo_endpoint": "https://test.accounts400.ondemand.com/oauth2/userinfo",
+ "end_session_endpoint": "https://test.accounts400.ondemand.com/oauth2/logout",
+ "oidc_metadata": "https://test.accounts400.ondemand.com/.well-known/openid-configuration",
+ "app_tid": "c992fd47-9eb4-4a7c-b932-9a8479d6c69f",
+ "partitionedCookies": true
+ }
+ """;
+
+ @Test
+ void testResolveReturnsSubdomainFromTokenEndpoint( final WireMockRuntimeInfo wm )
+ {
+ stubFor(
+ get(urlPathEqualTo("/sap/rest/tenantLoginInfo"))
+ .withQueryParam("id", equalTo(TENANT_ID))
+ .willReturn(okJson(RESPONSE)));
+
+ final URI btpTenantApiUri = URI.create(wm.getHttpBaseUrl());
+
+ assertThat(IasTenantHostResolver.DEFAULT_INSTANCE.resolve(btpTenantApiUri, TENANT_ID)).isEqualTo("test");
+
+ verify(
+ 1,
+ getRequestedFor(urlPathEqualTo("/sap/rest/tenantLoginInfo")).withQueryParam("id", equalTo(TENANT_ID)));
+ }
+
+ @Test
+ void testResolveThrowsOnNonOkResponse( final WireMockRuntimeInfo wm )
+ {
+ stubFor(get(urlPathEqualTo("/sap/rest/tenantLoginInfo")).willReturn(serverError()));
+
+ final URI btpTenantApiUri = URI.create(wm.getHttpBaseUrl());
+
+ assertThatThrownBy(() -> IasTenantHostResolver.DEFAULT_INSTANCE.resolve(btpTenantApiUri, TENANT_ID))
+ .isInstanceOf(DestinationAccessException.class)
+ .hasMessageContaining("status code 500")
+ .hasMessageContaining(TENANT_ID);
+ }
+
+ @Test
+ void testExtractSubdomainFromTokenEndpointParsesFirstHostLabel()
+ {
+ assertThat(IasTenantHostResolver.extractSubdomainFromTokenEndpoint(RESPONSE)).isEqualTo("test");
+ }
+
+ @Test
+ void testExtractSubdomainThrowsWhenTokenEndpointMissing()
+ {
+ final String body = """
+ { "status": "ACTIVE", "zoneId": "c992fd47-9eb4-4a7c-b932-9a8479d6c69f" }
+ """;
+ assertThatThrownBy(() -> IasTenantHostResolver.extractSubdomainFromTokenEndpoint(body))
+ .isInstanceOf(DestinationAccessException.class);
+ }
+
+ @Test
+ void testExtractSubdomainThrowsWhenTokenEndpointHasNoHost()
+ {
+ final String body = """
+ { "token_endpoint": "not-a-valid-url" }
+ """;
+ assertThatThrownBy(() -> IasTenantHostResolver.extractSubdomainFromTokenEndpoint(body))
+ .isInstanceOf(DestinationAccessException.class);
+ }
+}
diff --git a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBuilderTest.java b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBuilderTest.java
index 6085de406..0a3124cc6 100644
--- a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBuilderTest.java
+++ b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBuilderTest.java
@@ -147,4 +147,23 @@ void testTimeoutIsAdded()
sut.withTokenUri("https://foo.bar").withIdentity(new ClientCredentials("id", "secret"));
assertThat(sut.build().getResilienceConfiguration().timeLimiterConfiguration()).isSameAs(tl);
}
+
+ @Test
+ void testBtpTenantApiUriIsStored()
+ {
+ final URI base = URI.create("https://api.authentication.eu12.hana.ondemand.com");
+ assertThat(OAuth2Service.builder().withBtpTenantApiUri(base).getBtpTenantApiUri()).isEqualTo(base);
+ }
+
+ @Test
+ void testBtpTenantApiUriIsNullWhenNotSet()
+ {
+ assertThat(OAuth2Service.builder().getBtpTenantApiUri()).isNull();
+ }
+
+ @Test
+ void testBtpTenantApiUriNullInputIsNoop()
+ {
+ assertThat(OAuth2Service.builder().withBtpTenantApiUri(null).getBtpTenantApiUri()).isNull();
+ }
}
diff --git a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceTest.java b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceTest.java
index 28007bbae..859477da9 100644
--- a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceTest.java
+++ b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceTest.java
@@ -16,6 +16,7 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
@@ -43,6 +44,7 @@
import com.sap.cloud.sdk.cloudplatform.cache.CacheManager;
import com.sap.cloud.sdk.cloudplatform.connectivity.OAuth2Service.TenantPropagationStrategy;
import com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.ZtisClientIdentity;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationOAuthTokenException;
import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration;
import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceIsolationMode;
@@ -86,11 +88,14 @@ class OAuth2ServiceTest
@RegisterExtension
static TestContext context = TestContext.withThreadContext().resetCaches();
+ private final IasTenantHostResolver mockResolver = mock(IasTenantHostResolver.class);
+
@BeforeEach
void setUp()
{
SERVER_1.stubFor(post("/oauth/token").willReturn(okJson(RESPONSE_TEMPLATE.formatted(TOKEN_1))));
SERVER_2.stubFor(post("/oauth/token").willReturn(okJson(RESPONSE_TEMPLATE.formatted(TOKEN_2))));
+ doReturn("localhost").when(mockResolver).resolve(any(), any());
}
@Test
@@ -168,6 +173,7 @@ void testSubdomainTenantStrategy()
.withTokenUri(SERVER_1.baseUrl())
.withIdentity(IDENTITY_1)
.withAdditionalParameter("app_tid", "provider")
+ .withBtpTenantApiUri(URI.create("http://should.not.exist"))
.withTenantPropagationStrategy(TenantPropagationStrategy.TENANT_SUBDOMAIN);
{
// behalf: current tenant
@@ -178,10 +184,10 @@ void testSubdomainTenantStrategy()
TenantAccessor.executeWithTenant(new DefaultTenant("t1", "localhost"), service::retrieveAccessToken);
TenantAccessor.executeWithTenant(new DefaultTenant("t2", "localhost"), service::retrieveAccessToken);
- // if a tenant is explicitly defined, the subdomain is mandatory for the subdomain strategy
- assertThatThrownBy(
- () -> TenantAccessor.executeWithTenant(new DefaultTenant("t3"), service::retrieveAccessToken))
- .hasMessageContaining("does not have a subdomain");
+ // if a tenant without subdomain is given, the subdomain will be dynamically resolved using the BTP API
+ // mock the resolver to prevent
+ service.setIasTenantHostResolver(mockResolver);
+ TenantAccessor.executeWithTenant(new DefaultTenant("t3"), service::retrieveAccessToken);
SERVER_1
.verify(
@@ -189,6 +195,7 @@ void testSubdomainTenantStrategy()
postRequestedFor(urlEqualTo("/oauth/token")).withRequestBody(containing("app_tid=provider")));
SERVER_1.verify(1, postRequestedFor(urlEqualTo("/oauth/token")).withRequestBody(containing("app_tid=t1")));
SERVER_1.verify(1, postRequestedFor(urlEqualTo("/oauth/token")).withRequestBody(containing("app_tid=t2")));
+ SERVER_1.verify(1, postRequestedFor(urlEqualTo("/oauth/token")).withRequestBody(containing("app_tid=t3")));
}
{
// behalf provider
@@ -446,4 +453,44 @@ void testZeroTrustCertificateRotationCausesCacheMiss()
final OAuth2TokenService tokenService2 = service.getTokenService(null);
assertThat(tokenService2).isNotSameAs(tokenService1);
}
+
+ @Test
+ void testSubdomainStrategyThrowsWhenBtpApiUriMissing()
+ {
+ final OAuth2Service service =
+ OAuth2Service
+ .builder()
+ .withTokenUri(SERVER_1.baseUrl())
+ .withIdentity(IDENTITY_1)
+ .withTenantPropagationStrategy(TenantPropagationStrategy.TENANT_SUBDOMAIN)
+ // no withBtpTenantApiUri
+ .build();
+
+ assertThatThrownBy(
+ () -> TenantAccessor.executeWithTenant(new DefaultTenant("t1"), service::retrieveAccessToken))
+ .hasMessageContaining("BTP API URL is not given")
+ .hasMessageContaining("property 'btp-tenant-api'")
+ .hasRootCauseInstanceOf(DestinationAccessException.class);
+ }
+
+ @Test
+ void testSubdomainStrategyPropagatesResolverException()
+ {
+ final DestinationAccessException resolverError = new DestinationAccessException("resolver failed");
+ doThrow(resolverError).when(mockResolver).resolve(any(), any());
+
+ final OAuth2Service service =
+ OAuth2Service
+ .builder()
+ .withTokenUri(SERVER_1.baseUrl())
+ .withIdentity(IDENTITY_1)
+ .withBtpTenantApiUri(URI.create("http://should.not.exist"))
+ .withTenantPropagationStrategy(TenantPropagationStrategy.TENANT_SUBDOMAIN)
+ .build();
+ service.setIasTenantHostResolver(mockResolver);
+
+ assertThatThrownBy(
+ () -> TenantAccessor.executeWithTenant(new DefaultTenant("t1"), service::retrieveAccessToken))
+ .hasRootCause(resolverError);
+ }
}
diff --git a/release_notes.md b/release_notes.md
index 3112514a9..724cb1fd0 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -12,7 +12,8 @@
### ✨ New Functionality
--
+- OAuth token requests to IAS now attempt to dynamically resolve the IAS tenant host, if not given.
+ When the current tenant does not contain a subdomain, and the IAS service binding contains the property `btp-tenant-api`, then an HTTP call to that URL is performed to obtain the IAS tenant host.
### 📈 Improvements