Skip to content

Commit cb8fa69

Browse files
committed
feat(java): add forced access token refresh and expiry buffer
1 parent 4e3aa09 commit cb8fa69

3 files changed

Lines changed: 94 additions & 93 deletions

File tree

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,24 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild
180180
this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI());
181181
}
182182

183+
/**
184+
* Returns an access token that can be used for authentication. If the cache contains a valid access token,
185+
* it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server.
186+
* If forceRefresh is true, always fetches a new token regardless of cache.
187+
*
188+
* @param forceRefresh If true, forces fetching a new token from the server.
189+
* @return The access token in string format.
190+
* @throws AccessTokenException If it can't make a successful request or parse the TokenRequest.
191+
* @throws SigningJwsException If the signing of the JWS fails.
192+
*/
193+
public String getAccessToken(boolean forceRefresh) throws AccessTokenException, SigningJwsException {
194+
if (!forceRefresh && this.isCachedTokenValid()) {
195+
LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis()));
196+
return this.accessToken.toString();
197+
}
198+
return this.fetchAccessToken();
199+
}
200+
183201
/**
184202
* Returns an access token that can be used for authentication. If the cache contains a valid access token,
185203
* it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server. The access
@@ -265,8 +283,8 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti
265283
if (tokenRes.indicatesSuccess()) {
266284
this.accessToken = tokenRes.toSuccessResponse().getTokens().getAccessToken();
267285
this.accessTokenExpireTime =
268-
this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime());
269-
LOGGER.info("Fetched access token which expires in: {} seconds", this.accessToken.getLifetime());
286+
this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()) - Constants.ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS;
287+
LOGGER.info("Fetched access token which expires in: {} seconds (buffered)", this.accessToken.getLifetime());
270288
return this.accessToken.toString();
271289
}
272290

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ 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
18+
1619
private Constants() {
1720
throw new IllegalStateException("Utility class");
1821
}

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

Lines changed: 71 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.nimbusds.oauth2.sdk.http.HTTPResponse;
88
import org.junit.jupiter.api.BeforeAll;
99
import org.junit.jupiter.api.Test;
10+
import org.mockito.stubbing.OngoingStubbing;
1011

1112
import javax.net.ssl.HttpsURLConnection;
1213
import java.io.File;
@@ -253,24 +254,10 @@ void getAccessTokenCallingWithErroneousResponseRaisesAccessTokenException() thro
253254

254255
@Test
255256
void getAccessTokenCalledForTheFirstTimeReturnsANewAccessToken() throws Exception {
256-
HttpURLConnection mockedConn = mock(HttpURLConnection.class);
257-
URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn);
258-
Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse(
259-
mockedURL, "validConfig.txt"
260-
);
261-
262-
HTTPRequest mockedRequest = mock(HTTPRequest.class);
263-
TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy(
264-
HTTPResponse.SC_OK,
265-
"{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}",
266-
true,
267-
mockedRequest
268-
);
269-
270-
ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy));
271-
String accessToken = confidentialClientSpy.getAccessToken();
272-
257+
TestHarness harness = createClientWithTokens(899, "test token");
258+
String accessToken = harness.client.getAccessToken();
273259
assertEquals("test token", accessToken);
260+
verify(harness.httpRequestMock, times(1)).send();
274261
}
275262

276263
@Test
@@ -301,100 +288,93 @@ void getAccessTokenCalledWithRequestOptionsSetsProxyAndSSLSettings() throws Exce
301288

302289
@Test
303290
void getAccessTokenCalledTwiceBeforeExpirationReturnsSameAccessToken() throws Exception {
304-
HttpURLConnection mockedConn = mock(HttpURLConnection.class);
305-
URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn);
306-
Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse(
307-
mockedURL, "validConfig.txt"
308-
);
309-
310-
HTTPResponse res = new HTTPResponse(HTTPResponse.SC_OK);
311-
res.setContent("{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}");
312-
res.setHeader("Content-Type", "application/json;charset=utf-8");
313-
314-
AuthorizationGrant grant = new UnitTestGrant();
315-
URI uriSpy = spy(new URI("https://test.test.com/.test-test/test-test"));
316-
TokenRequest tokenRequestMock = spy(new TokenRequest(uriSpy, grant, new Scope()));
317-
318-
TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder());
291+
TestHarness harness = createClientWithTokens(899, "test token");
292+
String accessToken1 = harness.client.getAccessToken();
293+
String accessToken2 = harness.client.getAccessToken();
294+
assertEquals("test token", accessToken1);
295+
assertEquals("test token", accessToken2);
296+
verify(harness.httpRequestMock, times(1)).send();
297+
}
319298

320-
HTTPRequest httpRequestMock = mock(HTTPRequest.class);
299+
@Test
300+
void getAccessTokenCallingBeforeAndAfterExpirationReturnsDifferentAccessToken() throws Exception {
301+
TestHarness harness = createClientWithTokens(0, "test token 1", "test token 2");
302+
String accessToken1 = harness.client.getAccessToken();
303+
String accessToken2 = harness.client.getAccessToken();
304+
assertEquals("test token 1", accessToken1);
305+
assertEquals("test token 2", accessToken2);
306+
verify(harness.httpRequestMock, times(2)).send();
307+
}
321308

322-
doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build();
323-
doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest();
324-
doReturn(res).when(httpRequestMock).send();
309+
@Test
310+
void getAccessTokenWithForceRefreshTrueAlwaysFetchesNewToken() throws Exception {
311+
TestHarness harness = createClientWithTokens(899, "token1", "token2");
312+
String tokenA = harness.client.getAccessToken(true);
313+
String tokenB = harness.client.getAccessToken(true);
314+
assertEquals("token1", tokenA);
315+
assertEquals("token2", tokenB);
316+
verify(harness.httpRequestMock, times(2)).send();
317+
}
325318

326-
ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy));
319+
@Test
320+
void getAccessTokenWithForceRefreshFalseReturnsCachedTokenIfValid() throws Exception {
321+
TestHarness harness = createClientWithTokens(899, "tokenX");
322+
String token1 = harness.client.getAccessToken(false);
323+
String token2 = harness.client.getAccessToken(false);
324+
assertEquals("tokenX", token1);
325+
assertEquals("tokenX", token2);
326+
verify(harness.httpRequestMock, times(1)).send();
327+
}
327328

328-
String accessToken1 = confidentialClientSpy.getAccessToken();
329-
String accessToken2 = confidentialClientSpy.getAccessToken();
329+
@Test
330+
void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception {
331+
TestHarness harness = createClientWithTokens(899, "tokenA", "tokenB");
332+
String tokenA = harness.client.getAccessToken(true); // force fetch first (tokenA)
333+
String tokenB = harness.client.getAccessToken(false); // should use cached tokenA, not fetch tokenB
334+
assertEquals("tokenA", tokenA);
335+
assertEquals("tokenA", tokenB);
336+
verify(harness.httpRequestMock, times(1)).send();
337+
}
330338

331-
assertEquals("test token", accessToken1);
332-
assertEquals("test token", accessToken2);
333-
verify(httpRequestMock).send();
339+
private static class TestHarness {
340+
final ConfidentialClient client;
341+
final HTTPRequest httpRequestMock;
342+
TestHarness(ConfidentialClient client, HTTPRequest httpRequestMock) {
343+
this.client = client;
344+
this.httpRequestMock = httpRequestMock;
345+
}
334346
}
335347

336-
@Test
337-
void getAccessTokenCallingBeforeAndAfterExpirationReturnsDifferentAccessToken() throws Exception {
348+
private static TestHarness createClientWithTokens(int expiresInSeconds, String... tokens) throws Exception {
338349
HttpURLConnection mockedConn = mock(HttpURLConnection.class);
339350
URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn);
340-
Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse(
341-
mockedURL, "validConfig.txt"
342-
);
343-
344-
HTTPResponse res1 = new HTTPResponse(HTTPResponse.SC_OK);
345-
res1.setContent("{\"access_token\":\"test token 1\",\"token_type\":\"Bearer\",\"expires_in\":0}");
346-
res1.setHeader("Content-Type", "application/json;charset=utf-8");
347-
348-
HTTPResponse res2 = new HTTPResponse(HTTPResponse.SC_OK);
349-
res2.setContent("{\"access_token\":\"test token 2\",\"token_type\":\"Bearer\",\"expires_in\":0}");
350-
res2.setHeader("Content-Type", "application/json;charset=utf-8");
351+
Configuration configurationMock = getConfigSpyMockedResponse(mockedURL, "validConfig.txt");
351352

352353
AuthorizationGrant grant = new UnitTestGrant();
353354
URI uriSpy = spy(new URI("https://test.test.com/.test-test/test-test"));
354355
TokenRequest tokenRequestMock = spy(new TokenRequest(uriSpy, grant, new Scope()));
355-
356356
TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder());
357-
358357
HTTPRequest httpRequestMock = mock(HTTPRequest.class);
359358

359+
OngoingStubbing<HTTPResponse> stubbing = null;
360+
for (String token : tokens) {
361+
HTTPResponse res = new HTTPResponse(HTTPResponse.SC_OK);
362+
String body = String.format("{\"access_token\":\"%s\",\"token_type\":\"Bearer\",\"expires_in\":%d}", token, expiresInSeconds);
363+
res.setContent(body);
364+
res.setHeader("Content-Type", "application/json;charset=utf-8");
365+
if (stubbing == null) {
366+
stubbing = when(httpRequestMock.send());
367+
stubbing = stubbing.thenReturn(res);
368+
} else {
369+
stubbing = stubbing.thenReturn(res);
370+
}
371+
}
372+
360373
doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build();
361374
doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest();
362-
doReturn(res1).doReturn(res2).when(httpRequestMock).send();
363375

364376
ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy));
365-
366-
String accessToken1 = confidentialClientSpy.getAccessToken();
367-
String accessToken2 = confidentialClientSpy.getAccessToken();
368-
369-
assertEquals("test token 1", accessToken1);
370-
assertEquals("test token 2", accessToken2);
371-
verify(httpRequestMock, times(2)).send();
372-
}
373-
374-
@Test
375-
void getAccessTokenCallingWithSendErrorRaisesAccessTokenException() throws Exception {
376-
try {
377-
HttpURLConnection mockedConn = mock(HttpURLConnection.class);
378-
URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn);
379-
Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse(
380-
mockedURL, "validConfig.txt"
381-
);
382-
383-
HTTPRequest mockedRequest = mock(HTTPRequest.class);
384-
TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy(
385-
HTTPResponse.SC_OK,
386-
"{\"error_description\":\"Invalid request.\",\"error\":\"invalid_request\"}",
387-
false,
388-
mockedRequest
389-
);
390-
391-
ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy));
392-
393-
confidentialClientSpy.getAccessToken();
394-
fail();
395-
} catch (AccessTokenException e) {
396-
assertEquals("Error attempting to get the access token", e.getMessage());
397-
}
377+
return new TestHarness(confidentialClientSpy, httpRequestMock);
398378
}
399379

400380
private static URL getUrlMockResponse(String stringFile, HttpURLConnection mockedConn) throws IOException {

0 commit comments

Comments
 (0)