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