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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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=<tenantId>} 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand All @@ -237,7 +256,8 @@ public OAuth2Options build()
new HashMap<>(additionalTokenRetrievalParameters),
timeLimiter,
clientKeyStore,
tokenCacheParameters);
tokenCacheParameters,
btpTenantApiBaseUri);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Question)

What's the impact of the additional HTTP request?

  • assumed duration / timeout
  • which http connection pool
  • considered result caching?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single connection pool since there is only one connection, the URL is fixed for the BTP region. Requests should be fast, but no timeout & caching since this code is intended as temporary workaround.

}

@Nullable
Expand Down Expand Up @@ -341,6 +347,8 @@ static class Builder
private final Map<String, String> 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 )
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -455,7 +470,8 @@ OAuth2Service build()
tenantPropagationStrategy,
additionalParameters,
resilienceConfig,
tokenCacheParameters);
tokenCacheParameters,
btpTenantApiUri);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ DestinationHeaderProvider createHeaderProvider(
.withAdditionalParameters(oAuth2Options.getAdditionalTokenRetrievalParameters())
.withTimeLimiter(oAuth2Options.getTimeLimiter())
.withTokenCacheParameters(oAuth2Options.getTokenCacheParameters())
.withBtpTenantApiUri(oAuth2Options.getBtpTenantApiBaseUri())
.build();
return new OAuth2HeaderProvider(oAuth2Service, authHeader);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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()
{
Expand Down
Loading