Skip to content
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ public class Console {
}
```

> Token refresh note: `getAccessToken()` (or `getAccessToken(false)`) returns the cached token while valid. Call `getAccessToken(true)` to bypass the cache and force retrieval of a new token. (See [Access Token Proactive Expiry Offset](#access-token-proactive-expiry-offset) for details on token expiry handling.)

### Configure a Proxy

The Confidential Client accepts an additional optional parameter called `RequestOptions`. This can be created to specify a proxy for the client to use. Below is an example of how to do this:
Expand Down Expand Up @@ -178,6 +180,25 @@ RequestOptions reqOpt = RequestOptions.builder()
.build();
```

### Access Token Proactive Expiry Offset

The `ConfidentialClient` refreshes access tokens proactively before their actual server-declared expiry to reduce the risk of a token expiring mid-request (e.g. due to latency or clock skew).

Default behaviour:
- A 30 second (30,000 ms) proactive offset is applied automatically.
- Calls to `getAccessToken()` (or `getAccessToken(false)`) reuse the cached token while it is still considered valid under this adjusted expiry.
- `getAccessToken(true)` forces a fresh token unless one was very recently refreshed (within 5 seconds) to avoid unnecessary duplicate requests.

You can override the proactive offset by configuring it in `RequestOptions`:

#### Example
```java
RequestOptions options = RequestOptions.builder()
.accessTokenExpiryOffset(Duration.ofSeconds(90)) // 90 seconds
.build();
ConfidentialClient client = new ConfidentialClient("./path/to/config.json", options);
```

## Modules

Information about the various utility modules contained in this library can be found below.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -51,6 +52,8 @@ public class ConfidentialClient implements OAuth2Client {
private long jwsIssuedAt;
private long accessTokenExpireTime;
private AccessToken accessToken;
private final Duration accessTokenExpiryOffset;
private long lastRefreshTime;

/**
* Creates a new ConfidentialClient. When setting up the OAuth 2.0 client, this constructor reaches out to
Expand Down Expand Up @@ -119,7 +122,7 @@ public ConfidentialClient(final Configuration config, RequestOptions requestOpti
this.config = config;
LOGGER.debug("Finished initialising configuration");
this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions;

this.accessTokenExpiryOffset = this.requestOptions.getAccessTokenExpiryOffset();
this.requestProviderMetadata();
}

Expand Down Expand Up @@ -180,6 +183,36 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
}

/**
* Returns an access token that can be used for authentication. If the cache contains a valid access token,
* it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server.
* If forceRefresh is true, fetches a new token unless one was very recently refreshed (within 5 seconds)
* to avoid unnecessary duplicate requests from concurrent threads.
*
* @param forceRefresh If true, forces fetching a new token from the server.
* @return The access token in string format.
* @throws AccessTokenException If it can't make a successful request or parse the TokenRequest.
* @throws SigningJwsException If the signing of the JWS fails.
*/
public String getAccessToken(boolean forceRefresh) throws AccessTokenException, SigningJwsException {
if (this.isCachedTokenValid()) {
if (!forceRefresh) {
LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis()));
return this.accessToken.toString();
}

// Implement a grace period of 5 seconds to avoid unnecessary token refreshes
long currentTime = System.currentTimeMillis();
boolean recentlyRefreshed = (currentTime - this.lastRefreshTime) < 5000;
if (recentlyRefreshed) {
LOGGER.debug("Force refresh requested but token was recently refreshed within grace period, returning cached token");
return this.accessToken.toString();
}
}

return this.fetchAccessToken();
}

/**
* Returns an access token that can be used for authentication. If the cache contains a valid access token,
* it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server. The access
Expand All @@ -192,12 +225,7 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild
*/
@Override
public String getAccessToken() throws AccessTokenException, SigningJwsException {
if (this.isCachedTokenValid()) {
LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis()));
return this.accessToken.toString();
}

return this.fetchAccessToken();
return getAccessToken(false);
}

private void requestProviderMetadata() throws AuthServerMetadataContentException, AuthServerMetadataException {
Expand Down Expand Up @@ -264,9 +292,13 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti

if (tokenRes.indicatesSuccess()) {
this.accessToken = tokenRes.toSuccessResponse().getTokens().getAccessToken();
this.accessTokenExpireTime =
this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime());
LOGGER.info("Fetched access token which expires in: {} seconds", this.accessToken.getLifetime());
long lifetimeMillis = java.util.concurrent.TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime());
long offsetMillis = this.accessTokenExpiryOffset.toMillis();
long effectiveLifetime = lifetimeMillis - offsetMillis;
this.accessTokenExpireTime = this.jwsIssuedAt + effectiveLifetime;
LOGGER.info("Fetched access token (serverLifetime={}s, configuredOffset={}ms, effectiveLifetime={}ms)",
this.accessToken.getLifetime(), offsetMillis, effectiveLifetime);
this.lastRefreshTime = System.currentTimeMillis();
return this.accessToken.toString();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSocketFactory;
import java.net.Proxy;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Value
@Builder
Expand All @@ -22,4 +25,31 @@ public class RequestOptions {

@Builder.Default
String userAgent = "fds-sdk/java/utils/1.1.5 (" + System.getProperty("os.name") + "; Java" + System.getProperty("java.version") + ")";

/**
* Maximum allowed proactive refresh offset (894 seconds).
*/
public static final Duration MAX_PROACTIVE_OFFSET = Duration.ofSeconds(894);

private static final Logger LOG = LoggerFactory.getLogger(RequestOptions.class);

@Builder.Default
Duration accessTokenExpiryOffset = Duration.ofSeconds(30);


public static RequestOptionsBuilder builder() {
return new RequestOptionsBuilder() {

@Override
public RequestOptionsBuilder accessTokenExpiryOffset(Duration d) {
if (d == null) throw new IllegalArgumentException("accessTokenExpiryOffset cannot be null");
if (d.compareTo(MAX_PROACTIVE_OFFSET) > 0) {
LOG.warn("Configured accessTokenExpiryOffset {} exceeds max {}; clamped to {}.", d, MAX_PROACTIVE_OFFSET, MAX_PROACTIVE_OFFSET);
return super.accessTokenExpiryOffset(MAX_PROACTIVE_OFFSET);
}

return super.accessTokenExpiryOffset(d);
}
};
}
}
Loading