Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 All @@ -66,7 +69,7 @@ public class ConfidentialClient implements OAuth2Client {
public ConfidentialClient(final String configPath)
throws AuthServerMetadataContentException, AuthServerMetadataException,
ConfigurationException {
this(new Configuration(configPath));
this(new Configuration(configPath), RequestOptions.builder().build());
}

/**
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 All @@ -139,7 +142,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder
throws AuthServerMetadataContentException,
AuthServerMetadataException,
ConfigurationException {
this(new Configuration(configPath));
this(new Configuration(configPath), RequestOptions.builder().build());
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
}

Expand All @@ -157,7 +160,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder
protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder)
throws AuthServerMetadataContentException,
AuthServerMetadataException {
this(config);
this(config, RequestOptions.builder().build());
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
}

Expand All @@ -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