Skip to content

Commit d778c03

Browse files
committed
fix(java): configurable proactive offset
1 parent 837d3d9 commit d778c03

4 files changed

Lines changed: 108 additions & 10 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ RequestOptions reqOpt = RequestOptions.builder()
180180
.build();
181181
```
182182

183+
### Access Token Proactive Expiry Offset
184+
185+
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).
186+
187+
Default behaviour:
188+
- A 30 second (30,000 ms) proactive offset is applied automatically.
189+
- Calls to `getAccessToken()` (or `getAccessToken(false)`) reuse the cached token while it is still considered valid under this adjusted expiry.
190+
- `getAccessToken(true)` always forces a fresh token (bypasses cache).
191+
192+
You can override the proactive offset by supplying a custom value (milliseconds) via the constructor overload.
193+
194+
#### Example
195+
```java
196+
ConfidentialClient client10s = new ConfidentialClient(
197+
"./path/to/config.json",
198+
RequestOptions.builder().build(),
199+
10000L // 10 second proactive expiry offset
200+
);
201+
```
202+
183203
## Modules
184204

185205
Information about the various utility modules contained in this library can be found below.

src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public class ConfidentialClient implements OAuth2Client {
5151
private long jwsIssuedAt;
5252
private long accessTokenExpireTime;
5353
private AccessToken accessToken;
54+
private final long accessTokenExpiryOffsetMillis;
5455

5556
/**
5657
* Creates a new ConfidentialClient. When setting up the OAuth 2.0 client, this constructor reaches out to
@@ -66,7 +67,7 @@ public class ConfidentialClient implements OAuth2Client {
6667
public ConfidentialClient(final String configPath)
6768
throws AuthServerMetadataContentException, AuthServerMetadataException,
6869
ConfigurationException {
69-
this(new Configuration(configPath));
70+
this(new Configuration(configPath), RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
7071
}
7172

7273
/**
@@ -84,7 +85,19 @@ public ConfidentialClient(final String configPath)
8485
public ConfidentialClient(final String configPath, RequestOptions requestOptions)
8586
throws AuthServerMetadataContentException, AuthServerMetadataException,
8687
ConfigurationException {
87-
this(new Configuration(configPath), requestOptions);
88+
this(new Configuration(configPath), requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
89+
}
90+
91+
/**
92+
* Creates a new ConfidentialClient with a custom proactive expiry offset.
93+
* @param configPath path to config file
94+
* @param requestOptions request options (proxy/ssl)
95+
* @param accessTokenExpiryOffsetMillis milliseconds subtracted from server expiry (non-negative)
96+
*/
97+
public ConfidentialClient(final String configPath, RequestOptions requestOptions, long accessTokenExpiryOffsetMillis)
98+
throws AuthServerMetadataContentException, AuthServerMetadataException,
99+
ConfigurationException {
100+
this(new Configuration(configPath), requestOptions, accessTokenExpiryOffsetMillis);
88101
}
89102

90103
/**
@@ -99,7 +112,7 @@ public ConfidentialClient(final String configPath, RequestOptions requestOptions
99112
*/
100113
public ConfidentialClient(final Configuration config)
101114
throws AuthServerMetadataContentException, AuthServerMetadataException {
102-
this(config, RequestOptions.builder().build());
115+
this(config, RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
103116
}
104117

105118
/**
@@ -115,11 +128,22 @@ public ConfidentialClient(final Configuration config)
115128
*/
116129
public ConfidentialClient(final Configuration config, RequestOptions requestOptions)
117130
throws AuthServerMetadataContentException, AuthServerMetadataException {
131+
this(config, requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
132+
}
133+
134+
/**
135+
* Core constructor with configurable access token proactive expiry offset.
136+
* @param config configuration
137+
* @param requestOptions request options
138+
* @param accessTokenExpiryOffsetMillis milliseconds to subtract from token lifetime when computing internal expiry
139+
*/
140+
public ConfidentialClient(final Configuration config, RequestOptions requestOptions, long accessTokenExpiryOffsetMillis)
141+
throws AuthServerMetadataContentException, AuthServerMetadataException {
118142
Objects.requireNonNull(config, "Configuration object must not be null");
119143
this.config = config;
120144
LOGGER.debug("Finished initialising configuration");
121145
this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions;
122-
146+
this.accessTokenExpiryOffsetMillis = accessTokenExpiryOffsetMillis;
123147
this.requestProviderMetadata();
124148
}
125149

@@ -139,7 +163,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder
139163
throws AuthServerMetadataContentException,
140164
AuthServerMetadataException,
141165
ConfigurationException {
142-
this(new Configuration(configPath));
166+
this(new Configuration(configPath), RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
143167
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
144168
}
145169

@@ -157,7 +181,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder
157181
protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder)
158182
throws AuthServerMetadataContentException,
159183
AuthServerMetadataException {
160-
this(config);
184+
this(config, RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
161185
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
162186
}
163187

@@ -176,7 +200,7 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild
176200
protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder, RequestOptions requestOptions)
177201
throws AuthServerMetadataContentException,
178202
AuthServerMetadataException {
179-
this(config, requestOptions);
203+
this(config, requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS);
180204
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
181205
}
182206

@@ -283,7 +307,7 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti
283307
if (tokenRes.indicatesSuccess()) {
284308
this.accessToken = tokenRes.toSuccessResponse().getTokens().getAccessToken();
285309
this.accessTokenExpireTime =
286-
this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()) - Constants.ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS;
310+
this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()) - this.accessTokenExpiryOffsetMillis;
287311
LOGGER.info("Fetched access token which expires in: {} seconds (buffered)", this.accessToken.getLifetime());
288312
return this.accessToken.toString();
289313
}

src/main/java/com/factset/sdk/utils/authentication/Constants.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ public final class Constants {
1313
// default values
1414
public static final String FACTSET_WELL_KNOWN_URI = "https://auth.factset.com/.well-known/openid-configuration";
1515

16-
// Buffer (in milliseconds) to refresh access token before actual expiry
17-
public static final long ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS = 30000; // 30 seconds
16+
/**
17+
* Default buffer (in milliseconds) to refresh access token before actual expiry.
18+
*/
19+
public static final long DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS = 30000L;
1820

1921
private Constants() {
2022
throw new IllegalStateException("Utility class");

src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,58 @@ void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception
336336
verify(harness.httpRequestMock, times(1)).send();
337337
}
338338

339+
@Test
340+
void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() throws Exception {
341+
long offsetMillis = 50_000L;
342+
TestHarness harness = createClientTokenCustomOffset(offsetMillis);
343+
344+
String first = harness.client.getAccessToken();
345+
assertEquals("tokenSingle", first);
346+
verify(harness.httpRequestMock, times(1)).send();
347+
348+
java.lang.reflect.Field issuedAtField = ConfidentialClient.class.getDeclaredField("jwsIssuedAt");
349+
java.lang.reflect.Field expiryField = ConfidentialClient.class.getDeclaredField("accessTokenExpireTime");
350+
issuedAtField.setAccessible(true);
351+
expiryField.setAccessible(true);
352+
long issuedAt = (long) issuedAtField.get(harness.client);
353+
long internalExpiry = (long) expiryField.get(harness.client);
354+
long expectedDelta = 899_000L - offsetMillis;
355+
assertEquals(expectedDelta, internalExpiry - issuedAt, "Internal expiry should be lifetime - offset");
356+
357+
expiryField.set(harness.client, System.currentTimeMillis() - 1);
358+
359+
String second = harness.client.getAccessToken();
360+
assertEquals("tokenSingle", second);
361+
verify(harness.httpRequestMock, times(2)).send();
362+
}
363+
364+
private static TestHarness createClientTokenCustomOffset(long offsetMillis) throws Exception {
365+
HttpURLConnection mockedConn = mock(HttpURLConnection.class);
366+
URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn);
367+
Configuration configurationMock = getConfigSpyMockedResponse(mockedURL, "validConfig.txt");
368+
369+
AuthorizationGrant grant = new UnitTestGrant();
370+
URI uriSpy = spy(new URI("https://test.test.com/.test-test/test-test"));
371+
TokenRequest tokenRequestMock = spy(new TokenRequest(uriSpy, grant, new Scope()));
372+
TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder());
373+
HTTPRequest httpRequestMock = mock(HTTPRequest.class);
374+
375+
HTTPResponse res = new HTTPResponse(HTTPResponse.SC_OK);
376+
res.setContent("{\"access_token\":\"tokenSingle\",\"token_type\":\"Bearer\",\"expires_in\":899}");
377+
res.setHeader("Content-Type", "application/json;charset=utf-8");
378+
379+
doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build();
380+
doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest();
381+
when(httpRequestMock.send()).thenReturn(res, res);
382+
383+
ConfidentialClient client = new ConfidentialClient(configurationMock, RequestOptions.builder().build(), offsetMillis);
384+
java.lang.reflect.Field f = ConfidentialClient.class.getDeclaredField("tokenRequestBuilder");
385+
f.setAccessible(true);
386+
f.set(client, tokenRequestBuilderSpy);
387+
388+
return new TestHarness(client, httpRequestMock);
389+
}
390+
339391
private static class TestHarness {
340392
final ConfidentialClient client;
341393
final HTTPRequest httpRequestMock;

0 commit comments

Comments
 (0)