Skip to content

Commit 9c6739e

Browse files
MatKuhrJonas-Isr
andauthored
[IAS] Dynamically Resolve IAS Host On-Demand (#1177)
Co-authored-by: Jonas-Isr <jonas.israel@sap.com>
1 parent 1f0bc09 commit 9c6739e

10 files changed

Lines changed: 360 additions & 16 deletions

File tree

cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliers.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ public OAuth2Options getOAuth2Options()
195195
.peek(format -> builder.withTokenRetrievalParameter("token_format", format));
196196
}
197197
attachClientKeyStore(builder);
198+
getCredential(URI.class, "btp-tenant-api").peek(builder::withBtpTenantApiBaseUri);
198199

199200
return builder.build();
200201
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.sap.cloud.sdk.cloudplatform.connectivity;
2+
3+
import java.io.IOException;
4+
import java.net.URI;
5+
import java.nio.charset.StandardCharsets;
6+
7+
import javax.annotation.Nonnull;
8+
9+
import org.apache.hc.client5.http.classic.methods.HttpGet;
10+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
11+
import org.apache.hc.client5.http.impl.classic.HttpClients;
12+
import org.apache.hc.core5.http.HttpStatus;
13+
import org.apache.hc.core5.http.io.entity.EntityUtils;
14+
import org.json.JSONObject;
15+
16+
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
17+
18+
import lombok.extern.slf4j.Slf4j;
19+
import lombok.val;
20+
21+
/**
22+
* Resolves the IAS tenant host for a given tenant ID by querying the BTP tenant API.
23+
* <p>
24+
* The endpoint returns OIDC metadata including a {@code token_endpoint}. The IAS host subdomain is extracted from the
25+
* first host label of that URL, which identifies the IAS tenant.
26+
*/
27+
@Slf4j
28+
class IasTenantHostResolver
29+
{
30+
static final IasTenantHostResolver DEFAULT_INSTANCE = new IasTenantHostResolver();
31+
private static final String TENANT_INFO_ENDPOINT_TEMPLATE = "/sap/rest/tenantLoginInfo?id=%s";
32+
33+
private final CloseableHttpClient httpClient;
34+
35+
private IasTenantHostResolver()
36+
{
37+
this.httpClient = HttpClients.createDefault();
38+
}
39+
40+
/**
41+
* Queries {@code btpTenantApiUri} with {@code ?id=<tenantId>} and extracts the IAS tenant subdomain from the
42+
* {@code token_endpoint} field in the JSON response.
43+
*
44+
* @param btpTenantApiUri
45+
* The full URL of the BTP tenant login-info endpoint.
46+
* @param tenantId
47+
* The tenant ID (app_tid/subaccount ID) to look up.
48+
* @return The subdomain extracted from the {@code token_endpoint} host.
49+
* @throws DestinationAccessException
50+
* if the HTTP request fails, the response is not 200, or the subdomain cannot be parsed from the
51+
* response.
52+
*/
53+
@Nonnull
54+
String resolve( @Nonnull final URI btpTenantApiUri, @Nonnull final String tenantId )
55+
{
56+
val url = btpTenantApiUri.resolve(TENANT_INFO_ENDPOINT_TEMPLATE.formatted(tenantId));
57+
log.debug("Dynamically resolving IAS tenant host for tenant '{}' via {}.", tenantId, url);
58+
val req = new HttpGet(url);
59+
try {
60+
return httpClient.execute(req, response -> {
61+
if( response.getCode() != HttpStatus.SC_OK ) {
62+
throw new DestinationAccessException(
63+
"Failed to query BTP tenant API: Server returned status code %d for GET request to '%s'."
64+
.formatted(response.getCode(), url));
65+
}
66+
val body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
67+
return extractSubdomainFromTokenEndpoint(body);
68+
});
69+
}
70+
catch( IOException e ) {
71+
throw new DestinationAccessException("Failed to query BTP tenant API: " + e.getMessage(), e);
72+
}
73+
}
74+
75+
@Nonnull
76+
static String extractSubdomainFromTokenEndpoint( @Nonnull final String responseBody )
77+
{
78+
try {
79+
final String tokenEndpoint = new JSONObject(responseBody).getString("token_endpoint");
80+
final String host = URI.create(tokenEndpoint).getHost();
81+
return host.substring(0, host.indexOf('.'));
82+
}
83+
catch( final Exception e ) {
84+
throw new DestinationAccessException(
85+
"Failed to extract IAS tenant host from the BTP tenant API response. The response did not conform to the expected format: "
86+
+ responseBody,
87+
e);
88+
}
89+
}
90+
}

cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Options.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.sap.cloud.sdk.cloudplatform.connectivity;
22

3+
import java.net.URI;
34
import java.security.KeyStore;
45
import java.time.Duration;
56
import java.util.HashMap;
@@ -50,7 +51,7 @@ public final class OAuth2Options
5051
* for the target system connection.
5152
*/
5253
public static final OAuth2Options DEFAULT =
53-
new OAuth2Options(false, Map.of(), DEFAULT_TIMEOUT, null, DEFAULT_TOKEN_CACHE_PARAMETERS);
54+
new OAuth2Options(false, Map.of(), DEFAULT_TIMEOUT, null, DEFAULT_TOKEN_CACHE_PARAMETERS, null);
5455

5556
private final boolean skipTokenRetrieval;
5657
@Nonnull
@@ -80,6 +81,15 @@ public final class OAuth2Options
8081
@Getter
8182
private final TokenCacheParameters tokenCacheParameters;
8283

84+
/**
85+
* Base URI of the BTP tenant API endpoint from the IAS service binding (the {@code btp-tenant-api} credential).
86+
* When present, {@link OAuth2Service} uses it to derive a per-tenant token URL instead of the static {@code url}.
87+
* Package-private; not part of the public API.
88+
*/
89+
@Nullable
90+
@Getter( AccessLevel.PACKAGE )
91+
private final URI btpTenantApiBaseUri;
92+
8393
/**
8494
* Indicates whether to skip the OAuth2 token flow.
8595
*
@@ -124,6 +134,8 @@ public static class Builder
124134
private KeyStore clientKeyStore;
125135
private TimeLimiterConfiguration timeLimiter = DEFAULT_TIMEOUT;
126136
private TokenCacheParameters tokenCacheParameters = DEFAULT_TOKEN_CACHE_PARAMETERS;
137+
@Nullable
138+
private URI btpTenantApiBaseUri;
127139

128140
/**
129141
* Indicates whether to skip the OAuth2 token flow.
@@ -216,6 +228,13 @@ public Builder withTokenCacheParameters( @Nonnull final TokenCacheParameters tok
216228
return this;
217229
}
218230

231+
@Nonnull
232+
Builder withBtpTenantApiBaseUri( @Nullable final URI btpTenantApiBaseUri )
233+
{
234+
this.btpTenantApiBaseUri = btpTenantApiBaseUri;
235+
return this;
236+
}
237+
219238
/**
220239
* Creates a new {@link OAuth2Options} instance.
221240
*
@@ -237,7 +256,8 @@ public OAuth2Options build()
237256
new HashMap<>(additionalTokenRetrievalParameters),
238257
timeLimiter,
239258
clientKeyStore,
240-
tokenCacheParameters);
259+
tokenCacheParameters,
260+
btpTenantApiBaseUri);
241261
}
242262
}
243263

cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ class OAuth2Service
9191
private final ResilienceConfiguration resilienceConfiguration;
9292
@Nonnull
9393
private final TokenCacheParameters tokenCacheParameters;
94+
@Nullable
95+
private final URI btpTenantApiUri;
96+
@Nonnull
97+
private IasTenantHostResolver iasTenantHostResolver;
9498

9599
// package-private for testing
96100
@Nonnull
@@ -249,16 +253,16 @@ private String getTenantSubdomainOrNull( @Nullable final Tenant tenant )
249253
return null;
250254
}
251255

252-
if( !(tenant instanceof TenantWithSubdomain tenantWithSubdomain) ) {
253-
final String msg = "Unable to get subdomain of tenant '%s' because the instance is not an instance of %s.";
254-
throw new DestinationAccessException(msg.formatted(tenant, TenantWithSubdomain.class.getSimpleName()));
256+
if( tenant instanceof TenantWithSubdomain tenantWithSubdomain && tenantWithSubdomain.getSubdomain() != null ) {
257+
return tenantWithSubdomain.getSubdomain();
255258
}
256-
final var subdomain = tenantWithSubdomain.getSubdomain();
257-
if( subdomain == null ) {
259+
log.debug("IAS tenant host is unknown for tenant {}. Performing IAS host lookup.", tenant.getTenantId());
260+
if( btpTenantApiUri == null ) {
258261
throw new DestinationAccessException(
259-
"The given tenant '%s' does not have a subdomain defined.".formatted(tenant));
262+
"Failed to dynamically resolve IAS tenant host: The BTP API URL is not given. "
263+
+ "Ensure your IAS service binding contains the BTP tenant API URL in the property 'btp-tenant-api'.");
260264
}
261-
return subdomain;
265+
return iasTenantHostResolver.resolve(btpTenantApiUri, tenant.getTenantId());
262266
}
263267

264268
@Nullable
@@ -341,6 +345,10 @@ static class Builder
341345
private final Map<String, String> additionalParameters = new HashMap<>();
342346
private ResilienceConfiguration.TimeLimiterConfiguration timeLimiter = OAuth2Options.DEFAULT_TIMEOUT;
343347
private TokenCacheParameters tokenCacheParameters = OAuth2Options.DEFAULT_TOKEN_CACHE_PARAMETERS;
348+
@Nullable
349+
private URI btpTenantApiUri;
350+
@Nullable
351+
private IasTenantHostResolver iasTenantHostResolver;
344352

345353
@Nonnull
346354
Builder withTokenUri( @Nonnull final String tokenUri )
@@ -425,6 +433,20 @@ Builder withTokenCacheParameters( @Nonnull final TokenCacheParameters tokenCache
425433
return this;
426434
}
427435

436+
@Nonnull
437+
Builder withBtpTenantApiUri( @Nullable final URI btpTenantApiBaseUri )
438+
{
439+
this.btpTenantApiUri = btpTenantApiBaseUri;
440+
return this;
441+
}
442+
443+
@Nonnull
444+
Builder withIasTenantHostResolver( @Nullable final IasTenantHostResolver iasTenantHostResolver )
445+
{
446+
this.iasTenantHostResolver = iasTenantHostResolver;
447+
return this;
448+
}
449+
428450
@Nonnull
429451
OAuth2Service build()
430452
{
@@ -448,14 +470,19 @@ OAuth2Service build()
448470
// copy the additional parameters to prevent accidental manipulation after the `OAuth2Service` instance has been created.
449471
final Map<String, String> additionalParameters = new HashMap<>(this.additionalParameters);
450472

473+
final var resolver =
474+
iasTenantHostResolver != null ? iasTenantHostResolver : IasTenantHostResolver.DEFAULT_INSTANCE;
475+
451476
return new OAuth2Service(
452477
tokenUri,
453478
identity,
454479
onBehalfOf,
455480
tenantPropagationStrategy,
456481
additionalParameters,
457482
resilienceConfig,
458-
tokenCacheParameters);
483+
tokenCacheParameters,
484+
btpTenantApiUri,
485+
resolver);
459486
}
460487
}
461488

cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2ServiceBindingDestinationLoader.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ DestinationHeaderProvider createHeaderProvider(
327327
.withAdditionalParameters(oAuth2Options.getAdditionalTokenRetrievalParameters())
328328
.withTimeLimiter(oAuth2Options.getTimeLimiter())
329329
.withTokenCacheParameters(oAuth2Options.getTokenCacheParameters())
330+
.withBtpTenantApiUri(oAuth2Options.getBtpTenantApiBaseUri())
330331
.build();
331332
return new OAuth2HeaderProvider(oAuth2Service, authHeader);
332333
}

cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/BtpServicePropertySuppliersTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ class IdentityAuthenticationTest
456456
ServiceIdentifier.IDENTITY_AUTHENTICATION,
457457
entry("app_tid", PROVIDER_TENANT_ID),
458458
entry("url", PROVIDER_URL),
459+
entry("btp-tenant-api", "https://api.authentication.eu12.hana.ondemand.com"),
459460
entry("credential-type", "X509_GENERATED"),
460461
entry("clientid", "ias-client-id"),
461462
entry("key", getKey()),
@@ -482,6 +483,46 @@ void testNoParameters()
482483
.containsValue(PROVIDER_TENANT_ID);
483484
}
484485

486+
@Test
487+
void testBtpTenantApiIsLoadedIntoOAuth2Options()
488+
{
489+
final ServiceBindingDestinationOptions options =
490+
ServiceBindingDestinationOptions.forService(BINDING).build();
491+
492+
final OAuth2PropertySupplier sut = IDENTITY_AUTHENTICATION.resolve(options);
493+
494+
assertThat(sut).isNotNull();
495+
496+
final OAuth2Options oAuth2Options = sut.getOAuth2Options();
497+
assertThat(oAuth2Options.getBtpTenantApiBaseUri())
498+
.isNotNull()
499+
.hasToString("https://api.authentication.eu12.hana.ondemand.com");
500+
}
501+
502+
@Test
503+
void testBtpTenantApiIsAbsentWhenNotInBinding()
504+
{
505+
final ServiceBinding bindingWithoutBtpTenantApi =
506+
bindingWithCredentials(
507+
ServiceIdentifier.IDENTITY_AUTHENTICATION,
508+
entry("app_tid", PROVIDER_TENANT_ID),
509+
entry("url", PROVIDER_URL),
510+
entry("credential-type", "X509_GENERATED"),
511+
entry("clientid", "ias-client-id"),
512+
entry("key", getKey()),
513+
entry("certificate", getCert()));
514+
515+
final ServiceBindingDestinationOptions options =
516+
ServiceBindingDestinationOptions.forService(bindingWithoutBtpTenantApi).build();
517+
518+
final OAuth2PropertySupplier sut = IDENTITY_AUTHENTICATION.resolve(options);
519+
520+
assertThat(sut).isNotNull();
521+
522+
final OAuth2Options oAuth2Options = sut.getOAuth2Options();
523+
assertThat(oAuth2Options.getBtpTenantApiBaseUri()).isNull();
524+
}
525+
485526
@Test
486527
void testTargetUri()
487528
{

0 commit comments

Comments
 (0)