diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7b10fed..ec615b458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## 4.1.0 +- Add IAS Token Flows API in `token-client` (`com.sap.cloud.security.ias.tokenflows`) + - `IasTokenFlows` entry point with builder-style flows: + - `IasClientCredentialsTokenFlow` (`grant_type=client_credentials`) — technical-user tokens (no end-user context), also used for app-to-app calls without user identity + - `IasJwtBearerTokenFlow` (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`) — exchanges an existing user token while preserving the user identity (app-to-app with user context) + - `IasRefreshTokenFlow` (`grant_type=refresh_token`) — exchanges a refresh token issued by IAS for a new access token + - `IasDefaultEndpoints` resolves the IAS token endpoint from an `OAuth2ServiceConfiguration` + - Convenience factories `IasTokenFlows.fromConfiguration(...)` wire up the flows from a service binding, including tenant host resolution when the binding exposes it +- Add multi-tenant subscriber host resolution for IAS token flows + - `IasTenantHostResolver` queries the BTP tenant API (`/sap/rest/tenantLoginInfo?id={tenantId}`) to discover the subscriber's IAS subdomain and rewrites the configured IAS host before token requests + - Activated automatically when the IAS service binding carries a `btp-tenant-api` property; without that property the resolver is not constructed and flows continue to use the provider host + - Triggered when an `app_tid` is supplied on a flow (`clientCredentialsTokenFlow().appTid(...)` / `jwtBearerTokenFlow().appTid(...)`); flows without `app_tid` are unaffected + - Resolved subdomains are cached via Caffeine with `expireAfterWrite` semantics — within the TTL the cached value is returned without any BTP call; after the TTL the next resolve blocks once on the BTP API, then caches the fresh result + - Cache governed by `IasTenantHostCacheConfiguration` (`enabled` / `ttl` / `maxSize`). Default: enabled, 1h TTL, 1000 entries. Spring Boot consumers can bind via `sap.spring.security.ias.tenant-host-cache.*` (`IasTenantHostCacheProperties`) - Update dependencies: - Spring Boot: 4.0.6 → 4.1.0 - Spring Framework: 7.0.7 → 7.0.8 diff --git a/spring-security/src/main/java/com/sap/cloud/security/spring/config/IasTenantHostCacheProperties.java b/spring-security/src/main/java/com/sap/cloud/security/spring/config/IasTenantHostCacheProperties.java new file mode 100644 index 000000000..ab6268e50 --- /dev/null +++ b/spring-security/src/main/java/com/sap/cloud/security/spring/config/IasTenantHostCacheProperties.java @@ -0,0 +1,79 @@ +/** + * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors + *
+ * SPDX-License-Identifier: Apache-2.0 + */ +package com.sap.cloud.security.spring.config; + +import com.sap.cloud.security.ias.client.IasTenantHostCacheConfiguration; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Spring configuration for the IAS tenant host resolver cache. + *
+ * Bind from {@code application.yml} / {@code application.properties} under the prefix + * {@code sap.spring.security.ias.tenant-host-cache}: + * + *
{@code
+ * sap:
+ * spring:
+ * security:
+ * ias:
+ * tenant-host-cache:
+ * enabled: true # default: true
+ * ttl: 1h # default: 1 hour (any java.time.Duration string)
+ * max-size: 1000 # default: 1000
+ * }
+ *
+ * Exposes a {@link IasTenantHostCacheConfiguration} bean that an
+ * {@code IasTenantHostResolver} or auto-configuration can consume.
+ */
+@Configuration
+@ConfigurationProperties("sap.spring.security.ias.tenant-host-cache")
+public class IasTenantHostCacheProperties {
+
+ private boolean enabled = true;
+ private Duration ttl = Duration.ofHours(1);
+ private long maxSize = 1000L;
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(final boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public Duration getTtl() {
+ return ttl;
+ }
+
+ public void setTtl(final Duration ttl) {
+ this.ttl = ttl;
+ }
+
+ public long getMaxSize() {
+ return maxSize;
+ }
+
+ public void setMaxSize(final long maxSize) {
+ this.maxSize = maxSize;
+ }
+
+ /**
+ * Exposes the bound properties as an immutable {@link IasTenantHostCacheConfiguration}.
+ */
+ @Bean
+ public IasTenantHostCacheConfiguration iasTenantHostCacheConfiguration() {
+ return IasTenantHostCacheConfiguration.builder()
+ .enabled(enabled)
+ .ttl(ttl)
+ .maxSize(maxSize)
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/token-client/README.md b/token-client/README.md
index 994abcb7c..09249fbd1 100644
--- a/token-client/README.md
+++ b/token-client/README.md
@@ -49,11 +49,17 @@ Additionally, it offers an API with the [XsuaaTokenFlows](./src/main/java/com/sa
- [2.2. Client Credentials Token Flow](#client-credentials-token-flow)
- [2.3. Refresh Token Flow](#refresh-token-flow)
- [2.4. Password Token Flow](#password-token-flow)
-3. [Retry mechanism](#retry-mechanism)
- - [3.1. Java EE applications](#java-ee-applications)
- - [3.2. Spring Boot applications](#spring-boot-applications)
-4. [Troubleshooting](#troubleshooting)
-5. [Samples](#samples)
+3. [IAS Token Flows API usage](#ias-token-flows-api-usage)
+ - [Initialization](#initialization)
+ - [Client Credentials Token Flow](#client-credentials-token-flow-1)
+ - [JWT Bearer Token Flow](#jwt-bearer-token-flow-1)
+ - [Refresh Token Flow](#refresh-token-flow-1)
+ - [Multi-tenant: subscriber host resolution](#multi-tenant-subscriber-host-resolution)
+4. [Retry mechanism](#retry-mechanism)
+ - [4.1. Java EE applications](#java-ee-applications)
+ - [4.2. Spring Boot applications](#spring-boot-applications)
+5. [Troubleshooting](#troubleshooting)
+6. [Samples](#samples)
## Setup
For Spring Boot applications `TokenFlows` come autoconfigured with our `spring-security` or `spring-security-3` libraries and can be easily consumed by autowiring the `XsuaaTokenFlows` Bean. For more details see [1.1. Configuration for Spring Applications](#11-configuration-for-spring-applications) section.
@@ -393,6 +399,129 @@ OAuth2TokenResponse tokenResponse = tokenFlows.passwordTokenFlow()
.execute();
```
+## IAS Token Flows API usage
+
+The `IasTokenFlows` class (package `com.sap.cloud.security.ias.tokenflows`) provides the same builder-pattern entry point as `XsuaaTokenFlows`, but for tokens issued by SAP Cloud Identity Service (IAS). Three flows are supported:
+
+- **Client Credentials Token Flow** (`grant_type=client_credentials`) — technical-user tokens, i.e. tokens *without* an end-user context. Useful both for plain service-to-service calls and for app-to-app calls when no user identity needs to flow.
+- **JWT Bearer Token Flow** (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`) — exchanges an existing user token for a new token while preserving the user identity. Use this for app-to-app calls that need to carry the user context.
+- **Refresh Token Flow** (`grant_type=refresh_token`) — exchanges a refresh token previously issued by IAS for a new access token.
+
+### Initialization
+
+The simplest path is the `fromConfiguration(...)` factory, which derives the IAS endpoint, the client identity, and (if the binding allows it) the tenant host resolver from a single `OAuth2ServiceConfiguration`:
+
+```java
+OAuth2ServiceConfiguration iasConfig = Environments.getCurrent().getIasConfiguration();
+OAuth2TokenService tokenService = new DefaultOAuth2TokenService();
+
+IasTokenFlows tokenFlows = IasTokenFlows.fromConfiguration(tokenService, iasConfig);
+```
+
+If you need full control (custom endpoints, custom client identity, no resolver), use the direct constructor:
+
+```java
+IasTokenFlows tokenFlows = new IasTokenFlows(
+ tokenService,
+ new IasDefaultEndpoints(iasConfig),
+ iasConfig.getClientIdentity());
+```
+
+### Client Credentials Token Flow
+
+Use this for technical-user tokens (no end-user context). Set `resource(...)` for an app-to-app call:
+
+```java
+OAuth2TokenResponse response = tokenFlows.clientCredentialsTokenFlow()
+ .resource("target-application") // optional: target application identifier
+ .appTid(subscriberTenantId) // optional: triggers subscriber host resolution
+ .tokenFormat("jwt") // optional
+ .disableCache(true) // optionally disables the token cache for this request
+ .execute();
+```
+
+### JWT Bearer Token Flow
+
+Use this to exchange an incoming user token for a new token while preserving the user identity (app-to-app with user context):
+
+```java
+OAuth2TokenResponse response = tokenFlows.jwtBearerTokenFlow()
+ .token(incomingJwt) // required: the user's access token
+ .resource("target-application") // optional
+ .appTid(subscriberTenantId) // optional: triggers subscriber host resolution
+ .disableCache(true)
+ .execute();
+```
+
+### Refresh Token Flow
+
+Use this to exchange a refresh token previously issued by IAS for a new access token:
+
+```java
+OAuth2TokenResponse response = tokenFlows.refreshTokenFlow()
+ .refreshToken(refreshTokenValue) // required
+ .appTid(subscriberTenantId) // optional: triggers subscriber host resolution
+ .disableCache(true)
+ .execute();
+```
+
+### Multi-tenant: subscriber host resolution
+
+In a multi-tenant IAS application the token endpoint host differs per subscriber subdomain. The `IasTokenFlows` API resolves that subdomain at runtime when:
+
+1. The IAS service binding carries a `btp-tenant-api` property (the BTP tenant API base URI). The `fromConfiguration(...)` factories pick this up automatically.
+2. A flow is invoked with an `appTid(...)` value. Flows without `appTid` keep using the provider host.
+
+Under the hood, `IasTenantHostResolver` calls `GET {btp-tenant-api}/sap/rest/tenantLoginInfo?id={appTid}` and extracts the subscriber subdomain from the returned `token_endpoint`. The configured IAS host is then rewritten to the subscriber host before the token request is sent.
+
+If the binding does not expose `btp-tenant-api`, no resolver is constructed and flows operate against the provider host unchanged. You can also wire a resolver explicitly:
+
+```java
+IasTokenFlows tokenFlows = IasTokenFlows.fromConfiguration(
+ tokenService,
+ iasConfig,
+ URI.create("https://api.authentication.eu10.hana.ondemand.com"),
+ SecurityHttpClientProvider.createClient(iasConfig.getClientIdentity()));
+```
+
+#### Tenant host cache
+
+Resolved subdomains are cached so repeated requests for the same subscriber do not flood the BTP tenant API. The cache uses `expireAfterWrite` semantics:
+
+- While the cached value is fresh (within the TTL): returned directly, **no** BTP call
+- After the TTL has elapsed: the next resolve blocks once on the BTP API, then caches the fresh result for another TTL window
+
+Defaults: enabled, 1 hour TTL, max 1000 entries. Configure via `IasTenantHostCacheConfiguration`:
+
+```java
+IasTenantHostCacheConfiguration cacheConfig = IasTenantHostCacheConfiguration.builder()
+ .enabled(true)
+ .ttl(Duration.ofHours(2))
+ .maxSize(500)
+ .build();
+
+IasTokenFlows tokenFlows = IasTokenFlows.fromConfiguration(tokenService, iasConfig, cacheConfig);
+```
+
+To disable caching entirely (every resolve hits the BTP API): `.enabled(false)`.
+
+#### Spring Boot configuration
+
+Spring Boot consumers can bind the cache configuration declaratively via `IasTenantHostCacheProperties`:
+
+```yaml
+sap:
+ spring:
+ security:
+ ias:
+ tenant-host-cache:
+ enabled: true # default: true
+ ttl: 1h # default: 1h (any java.time.Duration string)
+ max-size: 1000 # default: 1000
+```
+
+The resulting `IasTenantHostCacheConfiguration` bean is picked up by the IAS auto-configuration.
+
## Retry mechanism
The retry feature (supported since version 3.6.0) uses
diff --git a/token-client/src/main/java/com/sap/cloud/security/ias/client/IasDefaultEndpoints.java b/token-client/src/main/java/com/sap/cloud/security/ias/client/IasDefaultEndpoints.java
new file mode 100644
index 000000000..2c46d558b
--- /dev/null
+++ b/token-client/src/main/java/com/sap/cloud/security/ias/client/IasDefaultEndpoints.java
@@ -0,0 +1,109 @@
+/**
+ * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
+ * + * SPDX-License-Identifier: Apache-2.0 + */ +package com.sap.cloud.security.ias.client; + +import com.sap.cloud.security.config.OAuth2ServiceConfiguration; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceEndpointsProvider; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.net.URI; + +import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull; +import static com.sap.cloud.security.xsuaa.util.UriUtil.expandPath; + +/** + * IAS-specific endpoints provider. Derives the token endpoint from the IAS base URL. + *
+ * Unlike XSUAA, IAS uses {@code /oauth2/token} and {@code /oauth2/certs} paths. + */ +public class IasDefaultEndpoints implements OAuth2ServiceEndpointsProvider { + + private static final String TOKEN_ENDPOINT = "/oauth2/token"; + private static final String AUTHORIZE_ENDPOINT = "/oauth2/authorize"; + private static final String KEYSET_ENDPOINT = "/oauth2/certs"; + + private final URI baseUri; + + /** + * Creates a new IasDefaultEndpoints from a base URI string. + * + * @param baseUri + * the IAS base URI, e.g. {@code https://mytenant.accounts.ondemand.com} + */ + public IasDefaultEndpoints(@Nonnull String baseUri) { + assertNotNull(baseUri, "IAS base URI must not be null."); + this.baseUri = URI.create(baseUri.endsWith("/") ? baseUri.substring(0, baseUri.length() - 1) : baseUri); + } + + /** + * Creates a new IasDefaultEndpoints from an {@link OAuth2ServiceConfiguration}. + * + * @param config + * the IAS service configuration + */ + public IasDefaultEndpoints(@Nonnull OAuth2ServiceConfiguration config) { + assertNotNull(config, "OAuth2ServiceConfiguration must not be null."); + this.baseUri = config.getUrl(); + } + + /** + * Creates a new IasDefaultEndpoints from a URI. + * + * @param baseUri + * the IAS base URI + */ + public IasDefaultEndpoints(@Nonnull URI baseUri) { + assertNotNull(baseUri, "IAS base URI must not be null."); + this.baseUri = baseUri; + } + + /** + * Returns a new IasDefaultEndpoints with the subdomain replaced by the given subscriber subdomain. + * + * @param subscriberSubdomain + * the subscriber's subdomain to use + * @return new endpoints instance pointing to the subscriber's IAS tenant + */ + public IasDefaultEndpoints withSubdomain(@Nullable String subscriberSubdomain) { + if (subscriberSubdomain == null || subscriberSubdomain.isBlank()) { + return this; + } + String host = baseUri.getHost(); + int dotIndex = host.indexOf('.'); + if (dotIndex < 0) { + return this; + } + String newHost = subscriberSubdomain + host.substring(dotIndex); + URI subscriberUri = URI.create(baseUri.getScheme() + "://" + newHost + + (baseUri.getPort() > 0 ? ":" + baseUri.getPort() : "")); + return new IasDefaultEndpoints(subscriberUri); + } + + @Override + public URI getTokenEndpoint() { + return expandPath(baseUri, TOKEN_ENDPOINT); + } + + @Override + public URI getAuthorizeEndpoint() { + return expandPath(baseUri, AUTHORIZE_ENDPOINT); + } + + @Override + public URI getJwksUri() { + return expandPath(baseUri, KEYSET_ENDPOINT); + } + + /** + * Returns the base URI of this IAS tenant. + * + * @return the base URI + */ + public URI getBaseUri() { + return baseUri; + } +} diff --git a/token-client/src/main/java/com/sap/cloud/security/ias/client/IasTenantHostCacheConfiguration.java b/token-client/src/main/java/com/sap/cloud/security/ias/client/IasTenantHostCacheConfiguration.java new file mode 100644 index 000000000..2edc5a606 --- /dev/null +++ b/token-client/src/main/java/com/sap/cloud/security/ias/client/IasTenantHostCacheConfiguration.java @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors + *
+ * SPDX-License-Identifier: Apache-2.0 + */ +package com.sap.cloud.security.ias.client; + +import jakarta.annotation.Nonnull; +import java.time.Duration; + +/** + * Immutable configuration for the {@link IasTenantHostResolver} subdomain cache. + *
+ * Use {@link #builder()} to create a customized configuration, or {@link #defaultConfiguration()} + * for sensible defaults (enabled, 1h TTL, max 1000 entries). + *
+ * Set {@link Builder#enabled(boolean)} to {@code false} to disable caching entirely; in that case + * every {@link IasTenantHostResolver#resolve(String)} call will hit the BTP tenant API. + */ +public record IasTenantHostCacheConfiguration(boolean enabled, @Nonnull Duration ttl, long maxSize) { + + public IasTenantHostCacheConfiguration { + if (ttl == null) { + throw new IllegalArgumentException("ttl must not be null"); + } + if (enabled && ttl.isNegative()) { + throw new IllegalArgumentException("ttl must not be negative"); + } + if (enabled && maxSize <= 0) { + throw new IllegalArgumentException("maxSize must be positive"); + } + } + + public static IasTenantHostCacheConfiguration defaultConfiguration() { + return builder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private boolean enabled = true; + private Duration ttl = Duration.ofHours(1); + private long maxSize = 1000L; + + private Builder() { + } + + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder ttl(@Nonnull Duration ttl) { + this.ttl = ttl; + return this; + } + + public Builder maxSize(long maxSize) { + this.maxSize = maxSize; + return this; + } + + public IasTenantHostCacheConfiguration build() { + return new IasTenantHostCacheConfiguration(enabled, ttl, maxSize); + } + } +} \ No newline at end of file diff --git a/token-client/src/main/java/com/sap/cloud/security/ias/client/IasTenantHostResolver.java b/token-client/src/main/java/com/sap/cloud/security/ias/client/IasTenantHostResolver.java new file mode 100644 index 000000000..2c8a0447c --- /dev/null +++ b/token-client/src/main/java/com/sap/cloud/security/ias/client/IasTenantHostResolver.java @@ -0,0 +1,178 @@ +/** + * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors + *
+ * SPDX-License-Identifier: Apache-2.0 + */ +package com.sap.cloud.security.ias.client; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.cloud.security.client.SecurityHttpClient; +import com.sap.cloud.security.client.SecurityHttpRequest; +import com.sap.cloud.security.client.SecurityHttpResponse; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves subscriber IAS tenant hosts dynamically by querying the BTP tenant API. + *
+ * When a multi-tenant IAS application needs to fetch tokens for a subscriber tenant, + * the IAS token endpoint URL changes based on the subscriber's subdomain. + * This resolver queries {@code /sap/rest/tenantLoginInfo?id={tenantId}} to discover + * the subscriber's token endpoint and extracts the subdomain from it. + *
+ * Resolved subdomains are cached via Caffeine. The cache behavior is controlled by an
+ * {@link IasTenantHostCacheConfiguration}; pass a config with {@code enabled=false} to disable
+ * caching entirely.
+ */
+public class IasTenantHostResolver {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IasTenantHostResolver.class);
+ private static final String TENANT_LOGIN_INFO_PATH = "/sap/rest/tenantLoginInfo";
+
+ private final URI btpTenantApiBaseUri;
+ private final SecurityHttpClient httpClient;
+ private final IasTenantHostCacheConfiguration cacheConfiguration;
+ @Nullable
+ private final Cache
+ * Queries the BTP tenant API; if caching is enabled, subsequent calls with the same tenant ID
+ * return the cached value until the TTL expires.
+ *
+ * @param tenantId
+ * the subscriber tenant ID (app_tid / subaccount ID)
+ * @return the resolved IAS subdomain, or {@code null} if resolution returned no subdomain
+ * @throws OAuth2ServiceException
+ * if the HTTP request fails with an error response
+ */
+ @Nullable
+ public String resolve(@Nonnull String tenantId) throws OAuth2ServiceException {
+ if (subdomainCache == null) {
+ return doResolve(tenantId);
+ }
+ AtomicReference
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.tokenflows;
+
+import com.sap.cloud.security.ias.client.IasDefaultEndpoints;
+import com.sap.cloud.security.ias.client.IasTenantHostCacheConfiguration;
+import com.sap.cloud.security.ias.client.IasTenantHostResolver;
+
+import com.sap.cloud.security.config.ClientIdentity;
+import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
+import com.sap.cloud.security.xsuaa.tokenflows.TokenFlowException;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
+
+/**
+ * A client credentials flow builder for IAS (Identity Authentication Service).
+ *
+ * Applications use this to request technical user tokens from IAS.
+ * Unlike the XSUAA counterpart, this flow uses IAS-specific parameters:
+ *
+ * This is the subscriber's tenant ID (subaccount ID). If set, the token will be scoped
+ * to this tenant. If a {@link IasTenantHostResolver} is configured, the subdomain
+ * will be resolved dynamically.
+ *
+ * @param appTid
+ * the tenant ID
+ * @return this builder.
+ */
+ public IasClientCredentialsTokenFlow appTid(@Nonnull String appTid) {
+ this.appTid = appTid;
+ return this;
+ }
+
+ /**
+ * Sets the target application name for app-to-app communication.
+ *
+ * This will be converted to the IAS resource URN format:
+ * {@code urn:sap:identity:application:provider:name:{applicationName}}
+ *
+ * @param applicationName
+ * the target application's registered name in IAS
+ * @return this builder.
+ */
+ public IasClientCredentialsTokenFlow resource(@Nonnull String applicationName) {
+ assertNotNull(applicationName, "Application name must not be null.");
+ this.resource = RESOURCE_URN_PREFIX + applicationName;
+ return this;
+ }
+
+ /**
+ * Sets the resource URN directly for app-to-app communication.
+ *
+ * Use this if you have a pre-built URN, e.g. with a client ID reference:
+ * {@code urn:sap:identity:application:provider:clientid:{clientId}}
+ *
+ * @param resourceUrn
+ * the full resource URN
+ * @return this builder.
+ */
+ public IasClientCredentialsTokenFlow resourceUrn(@Nonnull String resourceUrn) {
+ assertNotNull(resourceUrn, "Resource URN must not be null.");
+ this.resource = resourceUrn;
+ return this;
+ }
+
+ /**
+ * Sets the desired token format.
+ *
+ * @param tokenFormat
+ * the token format, e.g. "jwt"
+ * @return this builder.
+ */
+ public IasClientCredentialsTokenFlow tokenFormat(@Nonnull String tokenFormat) {
+ this.tokenFormat = tokenFormat;
+ return this;
+ }
+
+ /**
+ * Disables the token cache for this request.
+ *
+ * @param disableCache
+ * {@code true} to disable caching
+ * @return this builder.
+ */
+ public IasClientCredentialsTokenFlow disableCache(boolean disableCache) {
+ this.disableCache = disableCache;
+ return this;
+ }
+
+ /**
+ * Executes the client credentials flow against the IAS token endpoint.
+ *
+ * @return the OAuth2 token response
+ * @throws TokenFlowException
+ * if the token request fails
+ */
+ @Nonnull
+ public OAuth2TokenResponse execute() throws TokenFlowException {
+ Map
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.tokenflows;
+
+import com.sap.cloud.security.ias.client.IasDefaultEndpoints;
+import com.sap.cloud.security.ias.client.IasTenantHostCacheConfiguration;
+import com.sap.cloud.security.ias.client.IasTenantHostResolver;
+
+import com.sap.cloud.security.config.ClientIdentity;
+import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
+import com.sap.cloud.security.xsuaa.tokenflows.TokenFlowException;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static com.sap.cloud.security.ias.tokenflows.IasClientCredentialsTokenFlow.*;
+import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
+
+/**
+ * A JWT bearer token flow builder for IAS (Identity Authentication Service).
+ *
+ * Applications use this to exchange a user token (assertion) for a new token
+ * issued by IAS, typically for user propagation scenarios.
+ *
+ * Unlike the XSUAA counterpart, this flow uses IAS-specific parameters:
+ *
+ * If not set explicitly, the {@code app_tid} claim from the assertion token
+ * would typically be used. Setting it explicitly overrides any token-derived value.
+ *
+ * @param appTid
+ * the tenant ID
+ * @return this builder.
+ */
+ public IasJwtBearerTokenFlow appTid(@Nonnull String appTid) {
+ this.appTid = appTid;
+ return this;
+ }
+
+ /**
+ * Sets the target application name for app-to-app communication.
+ *
+ * Converted to URN format: {@code urn:sap:identity:application:provider:name:{applicationName}}
+ *
+ * @param applicationName
+ * the target application's registered name in IAS
+ * @return this builder.
+ */
+ public IasJwtBearerTokenFlow resource(@Nonnull String applicationName) {
+ assertNotNull(applicationName, "Application name must not be null.");
+ this.resource = RESOURCE_URN_PREFIX + applicationName;
+ return this;
+ }
+
+ /**
+ * Sets the resource URN directly for app-to-app communication.
+ *
+ * @param resourceUrn
+ * the full resource URN
+ * @return this builder.
+ */
+ public IasJwtBearerTokenFlow resourceUrn(@Nonnull String resourceUrn) {
+ assertNotNull(resourceUrn, "Resource URN must not be null.");
+ this.resource = resourceUrn;
+ return this;
+ }
+
+ /**
+ * Sets the desired token format.
+ *
+ * @param tokenFormat
+ * the token format, e.g. "jwt"
+ * @return this builder.
+ */
+ public IasJwtBearerTokenFlow tokenFormat(@Nonnull String tokenFormat) {
+ this.tokenFormat = tokenFormat;
+ return this;
+ }
+
+ /**
+ * Disables the token cache for this request.
+ *
+ * @param disableCache
+ * {@code true} to disable caching
+ * @return this builder.
+ */
+ public IasJwtBearerTokenFlow disableCache(boolean disableCache) {
+ this.disableCache = disableCache;
+ return this;
+ }
+
+ /**
+ * Executes the JWT bearer flow against the IAS token endpoint.
+ *
+ * @return the OAuth2 token response
+ * @throws IllegalStateException
+ * if no assertion token has been set
+ * @throws TokenFlowException
+ * if the token request fails
+ */
+ @Nonnull
+ public OAuth2TokenResponse execute() throws TokenFlowException {
+ if (assertion == null) {
+ throw new IllegalStateException(
+ "A JWT assertion token must be set before executing the flow. Use .token(jwtString).");
+ }
+
+ Map
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.tokenflows;
+
+import com.sap.cloud.security.ias.client.IasDefaultEndpoints;
+import com.sap.cloud.security.ias.client.IasTenantHostCacheConfiguration;
+import com.sap.cloud.security.ias.client.IasTenantHostResolver;
+
+import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
+
+import com.sap.cloud.security.config.ClientIdentity;
+import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
+import com.sap.cloud.security.xsuaa.tokenflows.TokenFlowException;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.net.URI;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A refresh token flow builder for IAS (Identity Authentication Service).
+ *
+ * Exchanges a previously issued refresh token for a new access token. IAS issues
+ * refresh tokens in the OIDC authorization-code flow; this builder lets a backend
+ * trade such a refresh token for a fresh access token via {@code grant_type=refresh_token}.
+ */
+public class IasRefreshTokenFlow {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IasRefreshTokenFlow.class);
+
+ private final OAuth2TokenService tokenService;
+ private final IasDefaultEndpoints endpointsProvider;
+ private final ClientIdentity clientIdentity;
+ @Nullable
+ private final IasTenantHostResolver tenantHostResolver;
+
+ private String refreshToken;
+ private String appTid;
+ private boolean disableCache = false;
+
+ IasRefreshTokenFlow(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull IasDefaultEndpoints endpointsProvider,
+ @Nonnull ClientIdentity clientIdentity,
+ @Nullable IasTenantHostResolver tenantHostResolver) {
+ assertNotNull(tokenService, "OAuth2TokenService must not be null.");
+ assertNotNull(endpointsProvider, "IasDefaultEndpoints must not be null.");
+ assertNotNull(clientIdentity, "ClientIdentity must not be null.");
+
+ this.tokenService = tokenService;
+ this.endpointsProvider = endpointsProvider;
+ this.clientIdentity = clientIdentity;
+ this.tenantHostResolver = tenantHostResolver;
+ }
+
+ /**
+ * Sets the mandatory refresh token to be exchanged for a new access token.
+ *
+ * @param refreshToken
+ * the refresh token previously issued by IAS
+ * @return this builder.
+ */
+ public IasRefreshTokenFlow refreshToken(@Nonnull String refreshToken) {
+ assertNotNull(refreshToken, "Refresh token must not be null.");
+ this.refreshToken = refreshToken;
+ return this;
+ }
+
+ /**
+ * Sets the subscriber tenant ID. Triggers subscriber-host resolution if an
+ * {@link IasTenantHostResolver} was wired into the parent {@link IasTokenFlows}.
+ *
+ * @param appTid
+ * the subscriber tenant ID
+ * @return this builder.
+ */
+ public IasRefreshTokenFlow appTid(@Nonnull String appTid) {
+ this.appTid = appTid;
+ return this;
+ }
+
+ /**
+ * Disables the token cache for this request.
+ *
+ * @param disableCache
+ * {@code true} to bypass the cache
+ * @return this builder.
+ */
+ public IasRefreshTokenFlow disableCache(boolean disableCache) {
+ this.disableCache = disableCache;
+ return this;
+ }
+
+ /**
+ * Executes the refresh token flow against the IAS token endpoint.
+ *
+ * @return the OAuth2 token response
+ * @throws TokenFlowException
+ * if the refresh token is missing or the token request fails
+ */
+ @Nonnull
+ public OAuth2TokenResponse execute() throws TokenFlowException {
+ if (refreshToken == null) {
+ throw new IllegalStateException(
+ "Refresh token not set. Make sure to have called the refreshToken() method on IasRefreshTokenFlow builder.");
+ }
+
+ URI tokenEndpoint = resolveTokenEndpoint();
+
+ try {
+ return tokenService.retrieveAccessTokenViaRefreshToken(
+ tokenEndpoint,
+ clientIdentity,
+ refreshToken,
+ null,
+ disableCache);
+ } catch (OAuth2ServiceException e) {
+ throw new TokenFlowException(
+ "Error refreshing IAS token with grant_type 'refresh_token': %s".formatted(e.getMessage()), e);
+ }
+ }
+
+ private URI resolveTokenEndpoint() throws TokenFlowException {
+ if (appTid != null && tenantHostResolver != null) {
+ try {
+ String subscriberSubdomain = tenantHostResolver.resolve(appTid);
+ if (subscriberSubdomain != null) {
+ return endpointsProvider.withSubdomain(subscriberSubdomain).getTokenEndpoint();
+ }
+ } catch (OAuth2ServiceException e) {
+ throw new TokenFlowException(
+ "Error resolving IAS tenant host for app_tid '%s': %s".formatted(appTid, e.getMessage()), e);
+ }
+ } else if (appTid != null && tenantHostResolver == null) {
+ LOGGER.warn("app_tid '{}' is set but no IasTenantHostResolver is configured. "
+ + "The token request will use the provider endpoint. "
+ + "Ensure the IAS service binding contains the '{}' property for dynamic tenant resolution.",
+ appTid, IasTokenFlows.BTP_TENANT_API_PROPERTY);
+ }
+ return endpointsProvider.getTokenEndpoint();
+ }
+}
diff --git a/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasTokenFlows.java b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasTokenFlows.java
new file mode 100644
index 000000000..df2f60998
--- /dev/null
+++ b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasTokenFlows.java
@@ -0,0 +1,313 @@
+/**
+ * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.tokenflows;
+
+import com.sap.cloud.security.ias.client.IasDefaultEndpoints;
+import com.sap.cloud.security.ias.client.IasTenantHostCacheConfiguration;
+import com.sap.cloud.security.ias.client.IasTenantHostResolver;
+
+import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
+
+import com.sap.cloud.security.client.SecurityHttpClient;
+import com.sap.cloud.security.client.SecurityHttpClientProvider;
+import com.sap.cloud.security.config.ClientIdentity;
+import com.sap.cloud.security.config.OAuth2ServiceConfiguration;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.io.Serial;
+import java.io.Serializable;
+import java.net.URI;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Entry point for IAS (Identity Authentication Service) token flows.
+ *
+ * Provides builder objects for executing OAuth2 token flows against IAS:
+ * Example usage:
+ *
+ * If the configuration contains a {@value #BTP_TENANT_API_PROPERTY} property,
+ * dynamic tenant host resolution is automatically enabled using the default
+ * {@link SecurityHttpClient} from the {@link SecurityHttpClientProvider}.
+ *
+ * @param tokenService
+ * the OAuth2 token service
+ * @param config
+ * the IAS service configuration (from service binding)
+ * @return configured IasTokenFlows instance (with tenant resolution if BTP API URI is present)
+ */
+ public static IasTokenFlows fromConfiguration(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull OAuth2ServiceConfiguration config) {
+ return fromConfiguration(tokenService, config, IasTenantHostCacheConfiguration.defaultConfiguration());
+ }
+
+ /**
+ * Convenience factory: creates IasTokenFlows from an {@link OAuth2ServiceConfiguration} with an
+ * explicit cache configuration for tenant host resolution.
+ *
+ * @param tokenService
+ * the OAuth2 token service
+ * @param config
+ * the IAS service configuration (from service binding)
+ * @param cacheConfiguration
+ * cache configuration for the tenant host resolver
+ * @return configured IasTokenFlows instance (with tenant resolution if BTP API URI is present)
+ */
+ public static IasTokenFlows fromConfiguration(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull OAuth2ServiceConfiguration config,
+ @Nonnull IasTenantHostCacheConfiguration cacheConfiguration) {
+ assertNotNull(config, "OAuth2ServiceConfiguration must not be null.");
+ assertNotNull(cacheConfiguration, "IasTenantHostCacheConfiguration must not be null.");
+ IasTenantHostResolver resolver = createResolverFromConfig(config, null, cacheConfiguration);
+ return new IasTokenFlows(tokenService, new IasDefaultEndpoints(config), config.getClientIdentity(), resolver);
+ }
+
+ /**
+ * Convenience factory: creates IasTokenFlows with an explicit HTTP client for tenant resolution.
+ *
+ * If the configuration contains a {@value #BTP_TENANT_API_PROPERTY} property,
+ * dynamic tenant host resolution is automatically enabled using the provided HTTP client.
+ *
+ * @param tokenService
+ * the OAuth2 token service
+ * @param config
+ * the IAS service configuration (from service binding)
+ * @param httpClient
+ * the HTTP client to use for BTP tenant API requests
+ * @return configured IasTokenFlows instance (with tenant resolution if BTP API URI is present)
+ */
+ public static IasTokenFlows fromConfiguration(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull OAuth2ServiceConfiguration config,
+ @Nonnull SecurityHttpClient httpClient) {
+ return fromConfiguration(tokenService, config, httpClient,
+ IasTenantHostCacheConfiguration.defaultConfiguration());
+ }
+
+ /**
+ * Convenience factory: creates IasTokenFlows with an explicit HTTP client and cache configuration.
+ *
+ * @param tokenService
+ * the OAuth2 token service
+ * @param config
+ * the IAS service configuration (from service binding)
+ * @param httpClient
+ * the HTTP client to use for BTP tenant API requests
+ * @param cacheConfiguration
+ * cache configuration for the tenant host resolver
+ * @return configured IasTokenFlows instance (with tenant resolution if BTP API URI is present)
+ */
+ public static IasTokenFlows fromConfiguration(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull OAuth2ServiceConfiguration config,
+ @Nonnull SecurityHttpClient httpClient,
+ @Nonnull IasTenantHostCacheConfiguration cacheConfiguration) {
+ assertNotNull(config, "OAuth2ServiceConfiguration must not be null.");
+ assertNotNull(httpClient, "SecurityHttpClient must not be null.");
+ assertNotNull(cacheConfiguration, "IasTenantHostCacheConfiguration must not be null.");
+ IasTenantHostResolver resolver = createResolverFromConfig(config, httpClient, cacheConfiguration);
+ return new IasTokenFlows(tokenService, new IasDefaultEndpoints(config), config.getClientIdentity(), resolver);
+ }
+
+ /**
+ * Convenience factory: creates IasTokenFlows with an explicit BTP tenant API URI.
+ *
+ * Use this when the BTP tenant API URI is not part of the service binding
+ * or needs to be overridden.
+ *
+ * @param tokenService
+ * the OAuth2 token service
+ * @param config
+ * the IAS service configuration (from service binding)
+ * @param btpTenantApiBaseUri
+ * the BTP tenant API base URI for subdomain resolution
+ * @param httpClient
+ * the HTTP client for tenant API requests
+ * @return configured IasTokenFlows instance with tenant resolution
+ */
+ public static IasTokenFlows fromConfiguration(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull OAuth2ServiceConfiguration config,
+ @Nonnull URI btpTenantApiBaseUri,
+ @Nonnull SecurityHttpClient httpClient) {
+ return fromConfiguration(tokenService, config, btpTenantApiBaseUri, httpClient,
+ IasTenantHostCacheConfiguration.defaultConfiguration());
+ }
+
+ /**
+ * Convenience factory: creates IasTokenFlows with an explicit BTP tenant API URI and cache configuration.
+ *
+ * @param tokenService
+ * the OAuth2 token service
+ * @param config
+ * the IAS service configuration (from service binding)
+ * @param btpTenantApiBaseUri
+ * the BTP tenant API base URI for subdomain resolution
+ * @param httpClient
+ * the HTTP client for tenant API requests
+ * @param cacheConfiguration
+ * cache configuration for the tenant host resolver
+ * @return configured IasTokenFlows instance with tenant resolution
+ */
+ public static IasTokenFlows fromConfiguration(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull OAuth2ServiceConfiguration config,
+ @Nonnull URI btpTenantApiBaseUri,
+ @Nonnull SecurityHttpClient httpClient,
+ @Nonnull IasTenantHostCacheConfiguration cacheConfiguration) {
+ assertNotNull(config, "OAuth2ServiceConfiguration must not be null.");
+ assertNotNull(btpTenantApiBaseUri, "BTP tenant API URI must not be null.");
+ assertNotNull(httpClient, "SecurityHttpClient must not be null.");
+ assertNotNull(cacheConfiguration, "IasTenantHostCacheConfiguration must not be null.");
+ IasTenantHostResolver resolver = new IasTenantHostResolver(btpTenantApiBaseUri, httpClient, cacheConfiguration);
+ return new IasTokenFlows(tokenService, new IasDefaultEndpoints(config), config.getClientIdentity(), resolver);
+ }
+
+ @Nullable
+ private static IasTenantHostResolver createResolverFromConfig(OAuth2ServiceConfiguration config,
+ @Nullable SecurityHttpClient httpClient,
+ IasTenantHostCacheConfiguration cacheConfiguration) {
+ String btpTenantApiUrl = config.getProperty(BTP_TENANT_API_PROPERTY);
+ if (btpTenantApiUrl == null || btpTenantApiUrl.isBlank()) {
+ LOGGER.debug("No '{}' property found in IAS service configuration. "
+ + "Dynamic tenant host resolution will not be available.", BTP_TENANT_API_PROPERTY);
+ return null;
+ }
+ URI btpTenantApiUri = URI.create(btpTenantApiUrl);
+ if (httpClient == null) {
+ httpClient = SecurityHttpClientProvider.createClient(config.getClientIdentity());
+ }
+ return new IasTenantHostResolver(btpTenantApiUri, httpClient, cacheConfiguration);
+ }
+
+ /**
+ * Creates a new Client Credentials Token Flow builder for IAS.
+ *
+ * Use this for technical-user tokens (no end-user context). Combine with
+ * {@link IasClientCredentialsTokenFlow#resource(String)} for app-to-app calls.
+ *
+ * @return the {@link IasClientCredentialsTokenFlow} builder
+ */
+ public IasClientCredentialsTokenFlow clientCredentialsTokenFlow() {
+ return new IasClientCredentialsTokenFlow(tokenService, endpointsProvider, clientIdentity, tenantHostResolver);
+ }
+
+ /**
+ * Creates a new JWT Bearer Token Flow builder for IAS.
+ *
+ * Use this to exchange an incoming user token for a new token while preserving the
+ * user identity (app-to-app with user context).
+ *
+ * @return the {@link IasJwtBearerTokenFlow} builder
+ */
+ public IasJwtBearerTokenFlow jwtBearerTokenFlow() {
+ return new IasJwtBearerTokenFlow(tokenService, endpointsProvider, clientIdentity, tenantHostResolver);
+ }
+
+ /**
+ * Creates a new Refresh Token Flow builder for IAS.
+ *
+ * Use this to exchange a refresh token previously issued by IAS for a new access token.
+ *
+ * @return the {@link IasRefreshTokenFlow} builder
+ */
+ public IasRefreshTokenFlow refreshTokenFlow() {
+ return new IasRefreshTokenFlow(tokenService, endpointsProvider, clientIdentity, tenantHostResolver);
+ }
+}
diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/client/IasDefaultEndpointsTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/client/IasDefaultEndpointsTest.java
new file mode 100644
index 000000000..ab9325023
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/ias/client/IasDefaultEndpointsTest.java
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class IasDefaultEndpointsTest {
+
+ private static final String IAS_BASE_URL = "https://provider.accounts.ondemand.com";
+
+ @Test
+ void getTokenEndpoint_returnsOAuth2TokenPath() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints(IAS_BASE_URL);
+ assertThat(endpoints.getTokenEndpoint())
+ .isEqualTo(URI.create("https://provider.accounts.ondemand.com/oauth2/token"));
+ }
+
+ @Test
+ void getAuthorizeEndpoint_returnsOAuth2AuthorizePath() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints(IAS_BASE_URL);
+ assertThat(endpoints.getAuthorizeEndpoint())
+ .isEqualTo(URI.create("https://provider.accounts.ondemand.com/oauth2/authorize"));
+ }
+
+ @Test
+ void getJwksUri_returnsOAuth2CertsPath() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints(IAS_BASE_URL);
+ assertThat(endpoints.getJwksUri())
+ .isEqualTo(URI.create("https://provider.accounts.ondemand.com/oauth2/certs"));
+ }
+
+ @Test
+ void constructor_withTrailingSlash_stripsIt() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints("https://provider.accounts.ondemand.com/");
+ assertThat(endpoints.getTokenEndpoint())
+ .isEqualTo(URI.create("https://provider.accounts.ondemand.com/oauth2/token"));
+ }
+
+ @Test
+ void constructor_withNullUri_throws() {
+ assertThatThrownBy(() -> new IasDefaultEndpoints((String) null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void withSubdomain_replacesFirstHostLabel() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints(IAS_BASE_URL);
+ IasDefaultEndpoints subscriberEndpoints = endpoints.withSubdomain("subscriber");
+
+ assertThat(subscriberEndpoints.getTokenEndpoint())
+ .isEqualTo(URI.create("https://subscriber.accounts.ondemand.com/oauth2/token"));
+ assertThat(subscriberEndpoints.getBaseUri())
+ .isEqualTo(URI.create("https://subscriber.accounts.ondemand.com"));
+ }
+
+ @Test
+ void withSubdomain_nullOrBlank_returnsSameInstance() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints(IAS_BASE_URL);
+ assertThat(endpoints.withSubdomain(null)).isSameAs(endpoints);
+ assertThat(endpoints.withSubdomain("")).isSameAs(endpoints);
+ assertThat(endpoints.withSubdomain(" ")).isSameAs(endpoints);
+ }
+
+ @Test
+ void withSubdomain_preservesPort() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints("https://provider.accounts.ondemand.com:8443");
+ IasDefaultEndpoints subscriberEndpoints = endpoints.withSubdomain("subscriber");
+
+ assertThat(subscriberEndpoints.getTokenEndpoint())
+ .isEqualTo(URI.create("https://subscriber.accounts.ondemand.com:8443/oauth2/token"));
+ }
+
+ @Test
+ void getBaseUri_returnsConfiguredUri() {
+ IasDefaultEndpoints endpoints = new IasDefaultEndpoints(IAS_BASE_URL);
+ assertThat(endpoints.getBaseUri()).isEqualTo(URI.create(IAS_BASE_URL));
+ }
+}
diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/client/IasTenantHostCacheConfigurationTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/client/IasTenantHostCacheConfigurationTest.java
new file mode 100644
index 000000000..5690f76e1
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/ias/client/IasTenantHostCacheConfigurationTest.java
@@ -0,0 +1,69 @@
+/**
+ * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class IasTenantHostCacheConfigurationTest {
+
+ @Test
+ void defaultConfiguration_hasSensibleDefaults() {
+ IasTenantHostCacheConfiguration config = IasTenantHostCacheConfiguration.defaultConfiguration();
+
+ assertThat(config.enabled()).isTrue();
+ assertThat(config.ttl()).isEqualTo(Duration.ofHours(1));
+ assertThat(config.maxSize()).isEqualTo(1000L);
+ }
+
+ @Test
+ void builder_overridesDefaults() {
+ IasTenantHostCacheConfiguration config = IasTenantHostCacheConfiguration.builder()
+ .enabled(false)
+ .ttl(Duration.ofMinutes(5))
+ .maxSize(42)
+ .build();
+
+ assertThat(config.enabled()).isFalse();
+ assertThat(config.ttl()).isEqualTo(Duration.ofMinutes(5));
+ assertThat(config.maxSize()).isEqualTo(42L);
+ }
+
+ @Test
+ void nullTtl_throws() {
+ assertThatThrownBy(() -> new IasTenantHostCacheConfiguration(true, null, 100))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("ttl");
+ }
+
+ @Test
+ void enabledWithNonPositiveMaxSize_throws() {
+ assertThatThrownBy(() -> IasTenantHostCacheConfiguration.builder().maxSize(0).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("maxSize");
+ }
+
+ @Test
+ void enabledWithNegativeTtl_throws() {
+ assertThatThrownBy(() -> IasTenantHostCacheConfiguration.builder().ttl(Duration.ofSeconds(-1)).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("ttl");
+ }
+
+ @Test
+ void disabled_skipsValidation() {
+ IasTenantHostCacheConfiguration config = IasTenantHostCacheConfiguration.builder()
+ .enabled(false)
+ .maxSize(0)
+ .build();
+
+ assertThat(config.enabled()).isFalse();
+ }
+}
\ No newline at end of file
diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/client/IasTenantHostResolverTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/client/IasTenantHostResolverTest.java
new file mode 100644
index 000000000..694eae843
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/ias/client/IasTenantHostResolverTest.java
@@ -0,0 +1,185 @@
+/**
+ * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.client;
+
+import com.sap.cloud.security.client.SecurityHttpClient;
+import com.sap.cloud.security.client.SecurityHttpRequest;
+import com.sap.cloud.security.client.SecurityHttpResponse;
+import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class IasTenantHostResolverTest {
+
+ private static final URI BTP_API_BASE_URI = URI.create("https://api.authentication.eu10.hana.ondemand.com");
+ private static final String TENANT_ID = "subscriber-tenant-123";
+
+ @Mock
+ private SecurityHttpClient mockHttpClient;
+
+ private IasTenantHostResolver cut;
+
+ @BeforeEach
+ void setup() {
+ cut = new IasTenantHostResolver(BTP_API_BASE_URI, mockHttpClient);
+ }
+
+ @Test
+ void resolve_extractsSubdomainFromTokenEndpoint() throws IOException {
+ String responseBody = """
+ {"token_endpoint": "https://subscriber.accounts.ondemand.com/oauth2/token"}""";
+ mockResponse(200, responseBody);
+
+ String subdomain = cut.resolve(TENANT_ID);
+
+ assertThat(subdomain).isEqualTo("subscriber");
+ }
+
+ @Test
+ void resolve_callsCorrectEndpoint() throws IOException {
+ String responseBody = """
+ {"token_endpoint": "https://sub.accounts.ondemand.com/oauth2/token"}""";
+ mockResponse(200, responseBody);
+
+ cut.resolve(TENANT_ID);
+
+ ArgumentCaptor
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.sap.cloud.security.ias.tokenflows;
+
+import com.sap.cloud.security.ias.client.IasDefaultEndpoints;
+import com.sap.cloud.security.ias.client.IasTenantHostCacheConfiguration;
+import com.sap.cloud.security.ias.client.IasTenantHostResolver;
+
+import com.sap.cloud.security.config.ClientCredentials;
+import com.sap.cloud.security.config.ClientIdentity;
+import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
+import com.sap.cloud.security.xsuaa.tokenflows.TokenFlowException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.net.URI;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class IasClientCredentialsTokenFlowTest {
+
+ private static final String IAS_BASE_URL = "https://provider.accounts.ondemand.com";
+ private static final URI IAS_TOKEN_ENDPOINT = URI.create(IAS_BASE_URL + "/oauth2/token");
+ private static final String ACCESS_TOKEN = "ias-access-token-abc123";
+
+ @Mock
+ private OAuth2TokenService mockTokenService;
+
+ @Mock
+ private IasTenantHostResolver mockTenantResolver;
+
+ private ClientIdentity clientIdentity;
+ private IasDefaultEndpoints endpointsProvider;
+ private IasClientCredentialsTokenFlow cut;
+
+ @BeforeEach
+ void setup() {
+ clientIdentity = new ClientCredentials("ias-client-id", "ias-client-secret");
+ endpointsProvider = new IasDefaultEndpoints(IAS_BASE_URL);
+ cut = new IasClientCredentialsTokenFlow(mockTokenService, endpointsProvider, clientIdentity, null);
+ }
+
+ @Test
+ void execute_withDefaults_callsTokenService() throws TokenFlowException, OAuth2ServiceException {
+ mockAccessToken();
+
+ OAuth2TokenResponse response = cut.execute();
+
+ assertThat(response.getAccessToken()).isEqualTo(ACCESS_TOKEN);
+ verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant(
+ eq(IAS_TOKEN_ENDPOINT),
+ eq(clientIdentity),
+ isNull(),
+ isNull(),
+ eq(Map.of()),
+ eq(false));
+ }
+
+ @Test
+ void execute_withAppTid_passesParameterToService() throws TokenFlowException, OAuth2ServiceException {
+ mockAccessToken();
+
+ cut.appTid("tenant-42").execute();
+
+ ArgumentCaptor
+ *
+ */
+public class IasClientCredentialsTokenFlow {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IasClientCredentialsTokenFlow.class);
+
+ static final String APP_TID = "app_tid";
+ static final String RESOURCE = "resource";
+ static final String TOKEN_FORMAT = "token_format";
+ static final String RESOURCE_URN_PREFIX = "urn:sap:identity:application:provider:name:";
+
+ private final OAuth2TokenService tokenService;
+ private final IasDefaultEndpoints endpointsProvider;
+ private final ClientIdentity clientIdentity;
+ private final IasTenantHostResolver tenantHostResolver;
+
+ private String appTid;
+ private String resource;
+ private String tokenFormat;
+ private boolean disableCache = false;
+
+ IasClientCredentialsTokenFlow(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull IasDefaultEndpoints endpointsProvider,
+ @Nonnull ClientIdentity clientIdentity,
+ @Nullable IasTenantHostResolver tenantHostResolver) {
+ assertNotNull(tokenService, "OAuth2TokenService must not be null.");
+ assertNotNull(endpointsProvider, "IasDefaultEndpoints must not be null.");
+ assertNotNull(clientIdentity, "ClientIdentity must not be null.");
+
+ this.tokenService = tokenService;
+ this.endpointsProvider = endpointsProvider;
+ this.clientIdentity = clientIdentity;
+ this.tenantHostResolver = tenantHostResolver;
+ }
+
+ /**
+ * Sets the tenant ID for multi-tenant token requests.
+ *
+ *
+ */
+public class IasJwtBearerTokenFlow {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IasJwtBearerTokenFlow.class);
+
+ private final OAuth2TokenService tokenService;
+ private final IasDefaultEndpoints endpointsProvider;
+ private final ClientIdentity clientIdentity;
+ private final IasTenantHostResolver tenantHostResolver;
+
+ private String assertion;
+ private String appTid;
+ private String resource;
+ private String tokenFormat;
+ private boolean disableCache = false;
+
+ IasJwtBearerTokenFlow(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull IasDefaultEndpoints endpointsProvider,
+ @Nonnull ClientIdentity clientIdentity,
+ @Nullable IasTenantHostResolver tenantHostResolver) {
+ assertNotNull(tokenService, "OAuth2TokenService must not be null.");
+ assertNotNull(endpointsProvider, "IasDefaultEndpoints must not be null.");
+ assertNotNull(clientIdentity, "ClientIdentity must not be null.");
+
+ this.tokenService = tokenService;
+ this.endpointsProvider = endpointsProvider;
+ this.clientIdentity = clientIdentity;
+ this.tenantHostResolver = tenantHostResolver;
+ }
+
+ /**
+ * Sets the JWT assertion (user token) that should be exchanged.
+ *
+ * @param assertion
+ * the JWT token value
+ * @return this builder.
+ */
+ public IasJwtBearerTokenFlow token(@Nonnull String assertion) {
+ assertNotNull(assertion, "Assertion token must not be null.");
+ this.assertion = assertion;
+ return this;
+ }
+
+ /**
+ * Sets the tenant ID for multi-tenant token requests.
+ *
+ *
+ *
+ * {@code
+ * IasTokenFlows tokenFlows = new IasTokenFlows(
+ * tokenService,
+ * iasServiceConfig.getUrl(),
+ * iasServiceConfig.getClientIdentity());
+ *
+ * // Technical-user token (no end-user context)
+ * OAuth2TokenResponse response = tokenFlows.clientCredentialsTokenFlow()
+ * .appTid(subscriberTenantId)
+ * .resource("target-application")
+ * .execute();
+ *
+ * // App-to-app with preserved user context
+ * OAuth2TokenResponse response = tokenFlows.jwtBearerTokenFlow()
+ * .token(incomingJwt)
+ * .resource("target-application")
+ * .execute();
+ *
+ * // Refresh a previously issued IAS access token
+ * OAuth2TokenResponse response = tokenFlows.refreshTokenFlow()
+ * .refreshToken(refreshTokenValue)
+ * .execute();
+ * }
+ */
+public class IasTokenFlows implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IasTokenFlows.class);
+
+ /**
+ * The property name in the IAS service binding that holds the BTP tenant API base URI.
+ */
+ public static final String BTP_TENANT_API_PROPERTY = "btp-tenant-api";
+
+ private final transient OAuth2TokenService tokenService;
+ private final IasDefaultEndpoints endpointsProvider;
+ private final ClientIdentity clientIdentity;
+ private final transient IasTenantHostResolver tenantHostResolver;
+
+ /**
+ * Creates a new IasTokenFlows instance.
+ *
+ * @param tokenService
+ * the OAuth2 token service for executing HTTP token requests
+ * @param endpointsProvider
+ * the IAS endpoints provider
+ * @param clientIdentity
+ * the client identity (client ID + secret or certificate)
+ */
+ public IasTokenFlows(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull IasDefaultEndpoints endpointsProvider,
+ @Nonnull ClientIdentity clientIdentity) {
+ this(tokenService, endpointsProvider, clientIdentity, null);
+ }
+
+ /**
+ * Creates a new IasTokenFlows instance with BTP tenant host resolution support.
+ *
+ * @param tokenService
+ * the OAuth2 token service for executing HTTP token requests
+ * @param endpointsProvider
+ * the IAS endpoints provider
+ * @param clientIdentity
+ * the client identity (client ID + secret or certificate)
+ * @param tenantHostResolver
+ * optional resolver for dynamic subscriber subdomain lookup
+ */
+ public IasTokenFlows(@Nonnull OAuth2TokenService tokenService,
+ @Nonnull IasDefaultEndpoints endpointsProvider,
+ @Nonnull ClientIdentity clientIdentity,
+ @Nullable IasTenantHostResolver tenantHostResolver) {
+ assertNotNull(tokenService, "OAuth2TokenService must not be null.");
+ assertNotNull(endpointsProvider, "IasDefaultEndpoints must not be null.");
+ assertNotNull(clientIdentity, "ClientIdentity must not be null.");
+
+ this.tokenService = tokenService;
+ this.endpointsProvider = endpointsProvider;
+ this.clientIdentity = clientIdentity;
+ this.tenantHostResolver = tenantHostResolver;
+ }
+
+ /**
+ * Convenience factory: creates IasTokenFlows from an {@link OAuth2ServiceConfiguration}.
+ *