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 subdomainCache; + + /** + * Creates a new resolver with the default cache configuration + * ({@link IasTenantHostCacheConfiguration#defaultConfiguration()}). + * + * @param btpTenantApiBaseUri + * base URI of the BTP tenant API, e.g. {@code https://api.authentication.eu10.hana.ondemand.com} + * @param httpClient + * HTTP client to use for requests + */ + public IasTenantHostResolver(@Nonnull URI btpTenantApiBaseUri, @Nonnull SecurityHttpClient httpClient) { + this(btpTenantApiBaseUri, httpClient, IasTenantHostCacheConfiguration.defaultConfiguration()); + } + + /** + * Creates a new resolver with an explicit cache configuration. + * + * @param btpTenantApiBaseUri + * base URI of the BTP tenant API + * @param httpClient + * HTTP client to use for requests + * @param cacheConfiguration + * cache configuration; pass a disabled config to skip caching + */ + public IasTenantHostResolver(@Nonnull URI btpTenantApiBaseUri, @Nonnull SecurityHttpClient httpClient, + @Nonnull IasTenantHostCacheConfiguration cacheConfiguration) { + this.btpTenantApiBaseUri = btpTenantApiBaseUri; + this.httpClient = httpClient; + this.cacheConfiguration = cacheConfiguration; + this.subdomainCache = cacheConfiguration.enabled() + ? Caffeine.newBuilder() + .expireAfterWrite(cacheConfiguration.ttl()) + .maximumSize(cacheConfiguration.maxSize()) + .build() + : null; + } + + /** + * Resolves the IAS subdomain for a given tenant ID. + *

+ * 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 thrown = new AtomicReference<>(); + String resolved = subdomainCache.get(tenantId, key -> { + try { + return doResolve(key); + } catch (OAuth2ServiceException e) { + thrown.set(e); + return null; + } + }); + if (thrown.get() != null) { + throw thrown.get(); + } + return resolved; + } + + private String doResolve(String tenantId) throws OAuth2ServiceException { + URI requestUri = URI.create(btpTenantApiBaseUri + TENANT_LOGIN_INFO_PATH + "?id=" + tenantId); + LOGGER.debug("Resolving IAS subdomain for tenant '{}' via {}", tenantId, requestUri); + + SecurityHttpRequest request = SecurityHttpRequest.newBuilder() + .method("GET") + .uri(requestUri) + .header("Accept", "application/json") + .build(); + + try { + SecurityHttpResponse response = httpClient.execute(request); + if (response.getStatusCode() != 200) { + LOGGER.warn("BTP tenant API returned status {} for tenant '{}': {}", + response.getStatusCode(), tenantId, response.getBody()); + throw new OAuth2ServiceException( + "Failed to resolve IAS subdomain for tenant '%s': HTTP %d".formatted( + tenantId, response.getStatusCode())); + } + String subdomain = extractSubdomainFromResponse(response.getBody()); + LOGGER.debug("Resolved IAS subdomain for tenant '{}': '{}'", tenantId, subdomain); + return subdomain; + } catch (IOException e) { + throw new OAuth2ServiceException( + "Failed to resolve IAS subdomain for tenant '%s': %s".formatted(tenantId, e.getMessage())); + } + } + + /** + * Extracts the IAS subdomain from the tenantLoginInfo API response. + * The response contains a {@code token_endpoint} field like + * {@code https://subscriber.accounts.ondemand.com/oauth2/token}. + * We extract the first host label as the subdomain. + */ + static String extractSubdomainFromResponse(String responseBody) { + JSONObject json = new JSONObject(responseBody); + String tokenEndpoint = json.getString("token_endpoint"); + URI tokenUri = URI.create(tokenEndpoint); + String host = tokenUri.getHost(); + int dotIndex = host.indexOf('.'); + if (dotIndex < 0) { + return host; + } + return host.substring(0, dotIndex); + } + + /** + * @return the cache configuration this resolver was constructed with + */ + @Nonnull + public IasTenantHostCacheConfiguration getCacheConfiguration() { + return cacheConfiguration; + } + + /** + * Clears the subdomain cache. No-op if caching is disabled. + */ + public void clearCache() { + if (subdomainCache != null) { + subdomainCache.invalidateAll(); + } + } +} diff --git a/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasClientCredentialsTokenFlow.java b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasClientCredentialsTokenFlow.java new file mode 100644 index 000000000..b32a10a29 --- /dev/null +++ b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasClientCredentialsTokenFlow.java @@ -0,0 +1,203 @@ +/** + * 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 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: + *

+ */ +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. + *

+ * 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 requestParameters = new HashMap<>(); + + if (appTid != null) { + requestParameters.put(APP_TID, appTid); + } + if (resource != null) { + requestParameters.put(RESOURCE, resource); + } + if (tokenFormat != null) { + requestParameters.put(TOKEN_FORMAT, tokenFormat); + } + + URI tokenEndpoint = resolveTokenEndpoint(); + + try { + return tokenService.retrieveAccessTokenViaClientCredentialsGrant( + tokenEndpoint, + clientIdentity, + null, + null, + requestParameters, + disableCache); + } catch (OAuth2ServiceException e) { + throw new TokenFlowException( + "Error requesting IAS technical user token with grant_type 'client_credentials': %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/IasJwtBearerTokenFlow.java b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasJwtBearerTokenFlow.java new file mode 100644 index 000000000..8dcd57927 --- /dev/null +++ b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasJwtBearerTokenFlow.java @@ -0,0 +1,217 @@ +/** + * 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 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: + *

+ */ +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. + *

+ * 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 requestParameters = new HashMap<>(); + + if (appTid != null) { + requestParameters.put(APP_TID, appTid); + } + if (resource != null) { + requestParameters.put(RESOURCE, resource); + } + if (tokenFormat != null) { + requestParameters.put(TOKEN_FORMAT, tokenFormat); + } + + URI tokenEndpoint = resolveTokenEndpoint(); + + try { + return tokenService.retrieveAccessTokenViaJwtBearerTokenGrant( + tokenEndpoint, + clientIdentity, + assertion, + null, + requestParameters, + disableCache); + } catch (OAuth2ServiceException e) { + throw new TokenFlowException( + "Error requesting IAS user token with grant_type 'urn:ietf:params:oauth:grant-type:jwt-bearer': %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/IasRefreshTokenFlow.java b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasRefreshTokenFlow.java new file mode 100644 index 000000000..47ec7d1cf --- /dev/null +++ b/token-client/src/main/java/com/sap/cloud/security/ias/tokenflows/IasRefreshTokenFlow.java @@ -0,0 +1,147 @@ +/** + * 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.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: + *

{@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}. + *

+ * 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 requestCaptor = ArgumentCaptor.forClass(SecurityHttpRequest.class); + verify(mockHttpClient).execute(requestCaptor.capture()); + SecurityHttpRequest request = requestCaptor.getValue(); + + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getUri().toString()) + .isEqualTo("https://api.authentication.eu10.hana.ondemand.com/sap/rest/tenantLoginInfo?id=subscriber-tenant-123"); + } + + @Test + void resolve_cachesResult() throws IOException { + String responseBody = """ + {"token_endpoint": "https://cached.accounts.ondemand.com/oauth2/token"}"""; + mockResponse(200, responseBody); + + String first = cut.resolve(TENANT_ID); + String second = cut.resolve(TENANT_ID); + + assertThat(first).isEqualTo("cached"); + assertThat(second).isEqualTo("cached"); + verify(mockHttpClient, times(1)).execute(any()); + } + + @Test + void resolve_errorResponse_throwsException() throws IOException { + mockResponse(404, "Not found"); + + assertThatThrownBy(() -> cut.resolve(TENANT_ID)) + .isInstanceOf(OAuth2ServiceException.class) + .hasMessageContaining("HTTP 404") + .hasMessageContaining(TENANT_ID); + } + + @Test + void resolve_ioException_throwsOAuth2ServiceException() throws IOException { + when(mockHttpClient.execute(any())).thenThrow(new IOException("Connection refused")); + + assertThatThrownBy(() -> cut.resolve(TENANT_ID)) + .isInstanceOf(OAuth2ServiceException.class) + .hasMessageContaining("Connection refused"); + } + + @Test + void clearCache_removesEntries() throws IOException { + String responseBody = """ + {"token_endpoint": "https://sub.accounts.ondemand.com/oauth2/token"}"""; + mockResponse(200, responseBody); + + cut.resolve(TENANT_ID); + cut.clearCache(); + cut.resolve(TENANT_ID); + + verify(mockHttpClient, times(2)).execute(any()); + } + + @Test + void resolve_cachingDisabled_callsBackendEveryTime() throws IOException { + IasTenantHostCacheConfiguration disabled = IasTenantHostCacheConfiguration.builder() + .enabled(false) + .build(); + IasTenantHostResolver disabledCut = new IasTenantHostResolver(BTP_API_BASE_URI, mockHttpClient, disabled); + String responseBody = """ + {"token_endpoint": "https://nocache.accounts.ondemand.com/oauth2/token"}"""; + mockResponse(200, responseBody); + + disabledCut.resolve(TENANT_ID); + disabledCut.resolve(TENANT_ID); + disabledCut.resolve(TENANT_ID); + + verify(mockHttpClient, times(3)).execute(any()); + } + + @Test + void resolve_cachingDisabled_clearCacheIsNoop() throws IOException { + IasTenantHostCacheConfiguration disabled = IasTenantHostCacheConfiguration.builder() + .enabled(false) + .build(); + IasTenantHostResolver disabledCut = new IasTenantHostResolver(BTP_API_BASE_URI, mockHttpClient, disabled); + + disabledCut.clearCache(); // must not throw + } + + @Test + void resolve_errorResponse_isNotCached() throws IOException { + mockResponse(500, "boom"); + + assertThatThrownBy(() -> cut.resolve(TENANT_ID)).isInstanceOf(OAuth2ServiceException.class); + assertThatThrownBy(() -> cut.resolve(TENANT_ID)).isInstanceOf(OAuth2ServiceException.class); + + verify(mockHttpClient, times(2)).execute(any()); + } + + @Test + void getCacheConfiguration_returnsConfiguredInstance() { + IasTenantHostCacheConfiguration custom = IasTenantHostCacheConfiguration.builder() + .ttl(Duration.ofMinutes(15)) + .maxSize(50) + .build(); + IasTenantHostResolver customCut = new IasTenantHostResolver(BTP_API_BASE_URI, mockHttpClient, custom); + + assertThat(customCut.getCacheConfiguration()).isSameAs(custom); + } + + @Test + void extractSubdomainFromResponse_handlesVariousFormats() { + assertThat(IasTenantHostResolver.extractSubdomainFromResponse( + """ + {"token_endpoint": "https://mycompany.accounts.ondemand.com/oauth2/token"}""")) + .isEqualTo("mycompany"); + + assertThat(IasTenantHostResolver.extractSubdomainFromResponse( + """ + {"token_endpoint": "https://deep-sub.accounts.cloud.sap/oauth2/token"}""")) + .isEqualTo("deep-sub"); + } + + private void mockResponse(int statusCode, String body) throws IOException { + SecurityHttpResponse response = new SecurityHttpResponse(statusCode, Map.of(), body); + when(mockHttpClient.execute(any())).thenReturn(response); + } +} diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasClientCredentialsTokenFlowTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasClientCredentialsTokenFlowTest.java new file mode 100644 index 000000000..070112709 --- /dev/null +++ b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasClientCredentialsTokenFlowTest.java @@ -0,0 +1,212 @@ +/** + * 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 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> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()).containsEntry("app_tid", "tenant-42"); + } + + @Test + void execute_withResource_convertsToUrn() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.resource("my-target-app").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()) + .containsEntry("resource", "urn:sap:identity:application:provider:name:my-target-app"); + } + + @Test + void execute_withResourceUrn_passesDirectly() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.resourceUrn("urn:sap:identity:application:provider:clientid:abc123").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()) + .containsEntry("resource", "urn:sap:identity:application:provider:clientid:abc123"); + } + + @Test + void execute_withTokenFormat_passesParameter() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.tokenFormat("jwt").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()).containsEntry("token_format", "jwt"); + } + + @Test + void execute_withDisableCache_passesFlag() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.disableCache(true).execute(); + + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), any(), eq(true)); + } + + @Test + void execute_withTenantResolver_resolvesSubscriberEndpoint() throws TokenFlowException, OAuth2ServiceException { + cut = new IasClientCredentialsTokenFlow(mockTokenService, endpointsProvider, clientIdentity, mockTenantResolver); + when(mockTenantResolver.resolve("subscriber-tenant")).thenReturn("subscriber"); + mockAccessToken(); + + cut.appTid("subscriber-tenant").execute(); + + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + eq(URI.create("https://subscriber.accounts.ondemand.com/oauth2/token")), + any(), any(), any(), any(), anyBoolean()); + } + + @Test + void execute_withTenantResolver_resolverReturnsNull_usesProviderEndpoint() + throws TokenFlowException, OAuth2ServiceException { + cut = new IasClientCredentialsTokenFlow(mockTokenService, endpointsProvider, clientIdentity, mockTenantResolver); + when(mockTenantResolver.resolve("unknown-tenant")).thenReturn(null); + mockAccessToken(); + + cut.appTid("unknown-tenant").execute(); + + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + eq(IAS_TOKEN_ENDPOINT), + any(), any(), any(), any(), anyBoolean()); + } + + @Test + void execute_serviceException_throwsTokenFlowException() throws OAuth2ServiceException { + when(mockTokenService.retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), any(), anyBoolean())) + .thenThrow(new OAuth2ServiceException("401 Unauthorized")); + + assertThatThrownBy(() -> cut.execute()) + .isInstanceOf(TokenFlowException.class) + .hasMessageContaining("IAS technical user token") + .hasMessageContaining("401 Unauthorized"); + } + + @Test + void execute_tenantResolverException_throwsTokenFlowException() throws OAuth2ServiceException { + cut = new IasClientCredentialsTokenFlow(mockTokenService, endpointsProvider, clientIdentity, mockTenantResolver); + when(mockTenantResolver.resolve("bad-tenant")) + .thenThrow(new OAuth2ServiceException("HTTP 500")); + + assertThatThrownBy(() -> cut.appTid("bad-tenant").execute()) + .isInstanceOf(TokenFlowException.class) + .hasMessageContaining("resolving IAS tenant host") + .hasMessageContaining("bad-tenant"); + } + + @Test + void execute_allParametersCombined() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.appTid("tenant-99") + .resource("target-app") + .tokenFormat("jwt") + .disableCache(true) + .execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaClientCredentialsGrant( + eq(IAS_TOKEN_ENDPOINT), eq(clientIdentity), isNull(), isNull(), + paramsCaptor.capture(), eq(true)); + + Map params = paramsCaptor.getValue(); + assertThat(params).containsEntry("app_tid", "tenant-99"); + assertThat(params).containsEntry("resource", "urn:sap:identity:application:provider:name:target-app"); + assertThat(params).containsEntry("token_format", "jwt"); + } + + private void mockAccessToken() throws OAuth2ServiceException { + OAuth2TokenResponse tokenResponse = new OAuth2TokenResponse(ACCESS_TOKEN, 3600, null); + when(mockTokenService.retrieveAccessTokenViaClientCredentialsGrant( + any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(tokenResponse); + } +} diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasJwtBearerTokenFlowTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasJwtBearerTokenFlowTest.java new file mode 100644 index 000000000..b8233712d --- /dev/null +++ b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasJwtBearerTokenFlowTest.java @@ -0,0 +1,196 @@ +/** + * 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 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 IasJwtBearerTokenFlowTest { + + 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-exchanged-token-xyz"; + private static final String USER_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.fake"; + + @Mock + private OAuth2TokenService mockTokenService; + + @Mock + private IasTenantHostResolver mockTenantResolver; + + private ClientIdentity clientIdentity; + private IasDefaultEndpoints endpointsProvider; + private IasJwtBearerTokenFlow cut; + + @BeforeEach + void setup() { + clientIdentity = new ClientCredentials("ias-client-id", "ias-client-secret"); + endpointsProvider = new IasDefaultEndpoints(IAS_BASE_URL); + cut = new IasJwtBearerTokenFlow(mockTokenService, endpointsProvider, clientIdentity, null); + } + + @Test + void execute_withoutToken_throwsIllegalState() { + assertThatThrownBy(() -> cut.execute()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("assertion token must be set"); + } + + @Test + void execute_withToken_callsJwtBearerGrant() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + OAuth2TokenResponse response = cut.token(USER_JWT).execute(); + + assertThat(response.getAccessToken()).isEqualTo(ACCESS_TOKEN); + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + eq(IAS_TOKEN_ENDPOINT), + eq(clientIdentity), + eq(USER_JWT), + isNull(), + eq(Map.of()), + eq(false)); + } + + @Test + void execute_withAppTid_passesParameter() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.token(USER_JWT).appTid("tenant-abc").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()).containsEntry("app_tid", "tenant-abc"); + } + + @Test + void execute_withResource_convertsToUrn() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.token(USER_JWT).resource("target-service").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()) + .containsEntry("resource", "urn:sap:identity:application:provider:name:target-service"); + } + + @Test + void execute_withResourceUrn_passesDirectly() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.token(USER_JWT).resourceUrn("urn:sap:identity:application:provider:clientid:xyz").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()) + .containsEntry("resource", "urn:sap:identity:application:provider:clientid:xyz"); + } + + @Test + void execute_withTokenFormat_passesParameter() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.token(USER_JWT).tokenFormat("jwt").execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), paramsCaptor.capture(), anyBoolean()); + assertThat(paramsCaptor.getValue()).containsEntry("token_format", "jwt"); + } + + @Test + void execute_withDisableCache_passesFlag() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.token(USER_JWT).disableCache(true).execute(); + + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), any(), eq(true)); + } + + @Test + void execute_withTenantResolver_resolvesSubscriberEndpoint() throws TokenFlowException, OAuth2ServiceException { + cut = new IasJwtBearerTokenFlow(mockTokenService, endpointsProvider, clientIdentity, mockTenantResolver); + when(mockTenantResolver.resolve("sub-tenant")).thenReturn("subscriber"); + mockAccessToken(); + + cut.token(USER_JWT).appTid("sub-tenant").execute(); + + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + eq(URI.create("https://subscriber.accounts.ondemand.com/oauth2/token")), + any(), any(), any(), any(), anyBoolean()); + } + + @Test + void execute_serviceException_throwsTokenFlowException() throws OAuth2ServiceException { + when(mockTokenService.retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), any(), anyBoolean())) + .thenThrow(new OAuth2ServiceException("invalid_grant")); + + assertThatThrownBy(() -> cut.token(USER_JWT).execute()) + .isInstanceOf(TokenFlowException.class) + .hasMessageContaining("IAS user token") + .hasMessageContaining("jwt-bearer") + .hasMessageContaining("invalid_grant"); + } + + @Test + void execute_allParametersCombined() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.token(USER_JWT) + .appTid("tenant-x") + .resource("app-y") + .tokenFormat("jwt") + .disableCache(true) + .execute(); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockTokenService).retrieveAccessTokenViaJwtBearerTokenGrant( + eq(IAS_TOKEN_ENDPOINT), eq(clientIdentity), eq(USER_JWT), isNull(), + paramsCaptor.capture(), eq(true)); + + Map params = paramsCaptor.getValue(); + assertThat(params).containsEntry("app_tid", "tenant-x"); + assertThat(params).containsEntry("resource", "urn:sap:identity:application:provider:name:app-y"); + assertThat(params).containsEntry("token_format", "jwt"); + } + + private void mockAccessToken() throws OAuth2ServiceException { + OAuth2TokenResponse tokenResponse = new OAuth2TokenResponse(ACCESS_TOKEN, 3600, null); + when(mockTokenService.retrieveAccessTokenViaJwtBearerTokenGrant( + any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(tokenResponse); + } +} diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasRefreshTokenFlowTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasRefreshTokenFlowTest.java new file mode 100644 index 000000000..62bedef10 --- /dev/null +++ b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasRefreshTokenFlowTest.java @@ -0,0 +1,145 @@ +/** + * 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 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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; + +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.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IasRefreshTokenFlowTest { + + 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 URI SUBSCRIBER_TOKEN_ENDPOINT = URI.create( + "https://subscriber.accounts.ondemand.com/oauth2/token"); + private static final String REFRESH_TOKEN = "ias-refresh-token-xyz"; + 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 IasRefreshTokenFlow cut; + + @BeforeEach + void setup() { + clientIdentity = new ClientCredentials("ias-client-id", "ias-client-secret"); + endpointsProvider = new IasDefaultEndpoints(IAS_BASE_URL); + cut = new IasRefreshTokenFlow(mockTokenService, endpointsProvider, clientIdentity, null); + } + + @Test + void execute_withRefreshToken_callsTokenService() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + OAuth2TokenResponse response = cut.refreshToken(REFRESH_TOKEN).execute(); + + assertThat(response.getAccessToken()).isEqualTo(ACCESS_TOKEN); + verify(mockTokenService).retrieveAccessTokenViaRefreshToken( + eq(IAS_TOKEN_ENDPOINT), + eq(clientIdentity), + eq(REFRESH_TOKEN), + isNull(), + eq(false)); + } + + @Test + void execute_withoutRefreshToken_throwsIllegalState() { + assertThatThrownBy(() -> cut.execute()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Refresh token not set"); + } + + @Test + void execute_withDisableCache_passesFlag() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.refreshToken(REFRESH_TOKEN).disableCache(true).execute(); + + verify(mockTokenService).retrieveAccessTokenViaRefreshToken( + any(), any(), any(), any(), eq(true)); + } + + @Test + void execute_withTenantResolver_resolvesSubscriberEndpoint() throws TokenFlowException, OAuth2ServiceException { + cut = new IasRefreshTokenFlow(mockTokenService, endpointsProvider, clientIdentity, mockTenantResolver); + when(mockTenantResolver.resolve("subscriber-tenant")).thenReturn("subscriber"); + mockAccessToken(); + + cut.refreshToken(REFRESH_TOKEN).appTid("subscriber-tenant").execute(); + + verify(mockTokenService).retrieveAccessTokenViaRefreshToken( + eq(SUBSCRIBER_TOKEN_ENDPOINT), + any(), any(), any(), anyBoolean()); + } + + @Test + void execute_withAppTidButNoResolver_usesProviderEndpoint() throws TokenFlowException, OAuth2ServiceException { + mockAccessToken(); + + cut.refreshToken(REFRESH_TOKEN).appTid("subscriber-tenant").execute(); + + verify(mockTokenService).retrieveAccessTokenViaRefreshToken( + eq(IAS_TOKEN_ENDPOINT), + any(), any(), any(), anyBoolean()); + } + + @Test + void execute_tokenServiceThrows_isWrappedInTokenFlowException() throws OAuth2ServiceException { + when(mockTokenService.retrieveAccessTokenViaRefreshToken(any(), any(), any(), any(), anyBoolean())) + .thenThrow(new OAuth2ServiceException("boom")); + + assertThatThrownBy(() -> cut.refreshToken(REFRESH_TOKEN).execute()) + .isInstanceOf(TokenFlowException.class) + .hasMessageContaining("grant_type 'refresh_token'"); + } + + @Test + void execute_resolverThrows_isWrappedInTokenFlowException() throws OAuth2ServiceException { + cut = new IasRefreshTokenFlow(mockTokenService, endpointsProvider, clientIdentity, mockTenantResolver); + when(mockTenantResolver.resolve("subscriber-tenant")) + .thenThrow(new OAuth2ServiceException("BTP API failed")); + + assertThatThrownBy(() -> cut.refreshToken(REFRESH_TOKEN).appTid("subscriber-tenant").execute()) + .isInstanceOf(TokenFlowException.class) + .hasMessageContaining("Error resolving IAS tenant host"); + } + + private void mockAccessToken() throws OAuth2ServiceException { + OAuth2TokenResponse mockResponse = new OAuth2TokenResponse(ACCESS_TOKEN, 3600L, null); + when(mockTokenService.retrieveAccessTokenViaRefreshToken(any(), any(), any(), any(), anyBoolean())) + .thenReturn(mockResponse); + } +} diff --git a/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasTokenFlowsTest.java b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasTokenFlowsTest.java new file mode 100644 index 000000000..7023ec98e --- /dev/null +++ b/token-client/src/test/java/com/sap/cloud/security/ias/tokenflows/IasTokenFlowsTest.java @@ -0,0 +1,111 @@ +/** + * 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 com.sap.cloud.security.config.ClientCredentials; +import com.sap.cloud.security.config.ClientIdentity; +import com.sap.cloud.security.config.OAuth2ServiceConfiguration; +import com.sap.cloud.security.xsuaa.client.OAuth2TokenService; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IasTokenFlowsTest { + + private static final String IAS_BASE_URL = "https://provider.accounts.ondemand.com"; + + @Mock + private OAuth2TokenService mockTokenService; + + @Mock + private OAuth2ServiceConfiguration mockConfig; + + @Test + void constructor_withNullTokenService_throws() { + assertThatThrownBy(() -> new IasTokenFlows(null, + new IasDefaultEndpoints(IAS_BASE_URL), + new ClientCredentials("id", "secret"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("OAuth2TokenService"); + } + + @Test + void constructor_withNullEndpoints_throws() { + assertThatThrownBy(() -> new IasTokenFlows(mockTokenService, + null, + new ClientCredentials("id", "secret"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IasDefaultEndpoints"); + } + + @Test + void constructor_withNullClientIdentity_throws() { + assertThatThrownBy(() -> new IasTokenFlows(mockTokenService, + new IasDefaultEndpoints(IAS_BASE_URL), + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ClientIdentity"); + } + + @Test + void clientCredentialsTokenFlow_returnsNonNull() { + ClientIdentity identity = new ClientCredentials("id", "secret"); + IasTokenFlows flows = new IasTokenFlows(mockTokenService, new IasDefaultEndpoints(IAS_BASE_URL), identity); + + assertThat(flows.clientCredentialsTokenFlow()).isNotNull(); + } + + @Test + void jwtBearerTokenFlow_returnsNonNull() { + ClientIdentity identity = new ClientCredentials("id", "secret"); + IasTokenFlows flows = new IasTokenFlows(mockTokenService, new IasDefaultEndpoints(IAS_BASE_URL), identity); + + assertThat(flows.jwtBearerTokenFlow()).isNotNull(); + } + + @Test + void clientCredentialsTokenFlow_eachCallReturnsNewInstance() { + ClientIdentity identity = new ClientCredentials("id", "secret"); + IasTokenFlows flows = new IasTokenFlows(mockTokenService, new IasDefaultEndpoints(IAS_BASE_URL), identity); + + IasClientCredentialsTokenFlow first = flows.clientCredentialsTokenFlow(); + IasClientCredentialsTokenFlow second = flows.clientCredentialsTokenFlow(); + + assertThat(first).isNotSameAs(second); + } + + @Test + void fromConfiguration_withoutBtpTenantApiProperty_createsFlowsWithoutResolver() { + ClientIdentity identity = new ClientCredentials("id", "secret"); + when(mockConfig.getUrl()).thenReturn(URI.create(IAS_BASE_URL)); + when(mockConfig.getClientIdentity()).thenReturn(identity); + when(mockConfig.getProperty("btp-tenant-api")).thenReturn(null); + + IasTokenFlows flows = IasTokenFlows.fromConfiguration(mockTokenService, mockConfig); + + assertThat(flows).isNotNull(); + assertThat(flows.clientCredentialsTokenFlow()).isNotNull(); + assertThat(flows.jwtBearerTokenFlow()).isNotNull(); + } + + @Test + void btpTenantApiProperty_constant_hasCorrectValue() { + assertThat(IasTokenFlows.BTP_TENANT_API_PROPERTY).isEqualTo("btp-tenant-api"); + } +}