Skip to content
Open
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
* <p>
* 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.
* <p>
* Bind from {@code application.yml} / {@code application.properties} under the prefix
* {@code sap.spring.security.ias.tenant-host-cache}:
*
* <pre>{@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
* }</pre>
*
* 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();
}
}
139 changes: 134 additions & 5 deletions token-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
* <p>
* 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.
* <p>
* 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;
}
}
Loading