From cb8fa69462578810e98a4f09e7584501c6686a59 Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Tue, 30 Sep 2025 12:46:13 +0100 Subject: [PATCH 1/8] feat(java): add forced access token refresh and expiry buffer --- .../authentication/ConfidentialClient.java | 22 ++- .../sdk/utils/authentication/Constants.java | 3 + .../ConfidentialClientTest.java | 162 ++++++++---------- 3 files changed, 94 insertions(+), 93 deletions(-) diff --git a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java index 1c23368..cbe6b4e 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -180,6 +180,24 @@ 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, always fetches a new token regardless of cache. + * + * @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 (!forceRefresh && 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(); + } + /** * 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 @@ -265,8 +283,8 @@ 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()); + this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()) - Constants.ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS; + LOGGER.info("Fetched access token which expires in: {} seconds (buffered)", this.accessToken.getLifetime()); return this.accessToken.toString(); } diff --git a/src/main/java/com/factset/sdk/utils/authentication/Constants.java b/src/main/java/com/factset/sdk/utils/authentication/Constants.java index ed60b44..0b0b01d 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/Constants.java +++ b/src/main/java/com/factset/sdk/utils/authentication/Constants.java @@ -13,6 +13,9 @@ public final class Constants { // default values public static final String FACTSET_WELL_KNOWN_URI = "https://auth.factset.com/.well-known/openid-configuration"; + // Buffer (in milliseconds) to refresh access token before actual expiry + public static final long ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS = 30000; // 30 seconds + private Constants() { throw new IllegalStateException("Utility class"); } diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index 474862b..b9ae831 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -7,6 +7,7 @@ import com.nimbusds.oauth2.sdk.http.HTTPResponse; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.mockito.stubbing.OngoingStubbing; import javax.net.ssl.HttpsURLConnection; import java.io.File; @@ -253,24 +254,10 @@ void getAccessTokenCallingWithErroneousResponseRaisesAccessTokenException() thro @Test void getAccessTokenCalledForTheFirstTimeReturnsANewAccessToken() throws Exception { - HttpURLConnection mockedConn = mock(HttpURLConnection.class); - URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); - Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( - mockedURL, "validConfig.txt" - ); - - HTTPRequest mockedRequest = mock(HTTPRequest.class); - TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy( - HTTPResponse.SC_OK, - "{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}", - true, - mockedRequest - ); - - ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); - String accessToken = confidentialClientSpy.getAccessToken(); - + TestHarness harness = createClientWithTokens(899, "test token"); + String accessToken = harness.client.getAccessToken(); assertEquals("test token", accessToken); + verify(harness.httpRequestMock, times(1)).send(); } @Test @@ -301,100 +288,93 @@ void getAccessTokenCalledWithRequestOptionsSetsProxyAndSSLSettings() throws Exce @Test void getAccessTokenCalledTwiceBeforeExpirationReturnsSameAccessToken() throws Exception { - HttpURLConnection mockedConn = mock(HttpURLConnection.class); - URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); - Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( - mockedURL, "validConfig.txt" - ); - - HTTPResponse res = new HTTPResponse(HTTPResponse.SC_OK); - res.setContent("{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}"); - res.setHeader("Content-Type", "application/json;charset=utf-8"); - - AuthorizationGrant grant = new UnitTestGrant(); - URI uriSpy = spy(new URI("https://test.test.com/.test-test/test-test")); - TokenRequest tokenRequestMock = spy(new TokenRequest(uriSpy, grant, new Scope())); - - TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder()); + TestHarness harness = createClientWithTokens(899, "test token"); + String accessToken1 = harness.client.getAccessToken(); + String accessToken2 = harness.client.getAccessToken(); + assertEquals("test token", accessToken1); + assertEquals("test token", accessToken2); + verify(harness.httpRequestMock, times(1)).send(); + } - HTTPRequest httpRequestMock = mock(HTTPRequest.class); + @Test + void getAccessTokenCallingBeforeAndAfterExpirationReturnsDifferentAccessToken() throws Exception { + TestHarness harness = createClientWithTokens(0, "test token 1", "test token 2"); + String accessToken1 = harness.client.getAccessToken(); + String accessToken2 = harness.client.getAccessToken(); + assertEquals("test token 1", accessToken1); + assertEquals("test token 2", accessToken2); + verify(harness.httpRequestMock, times(2)).send(); + } - doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build(); - doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest(); - doReturn(res).when(httpRequestMock).send(); + @Test + void getAccessTokenWithForceRefreshTrueAlwaysFetchesNewToken() throws Exception { + TestHarness harness = createClientWithTokens(899, "token1", "token2"); + String tokenA = harness.client.getAccessToken(true); + String tokenB = harness.client.getAccessToken(true); + assertEquals("token1", tokenA); + assertEquals("token2", tokenB); + verify(harness.httpRequestMock, times(2)).send(); + } - ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); + @Test + void getAccessTokenWithForceRefreshFalseReturnsCachedTokenIfValid() throws Exception { + TestHarness harness = createClientWithTokens(899, "tokenX"); + String token1 = harness.client.getAccessToken(false); + String token2 = harness.client.getAccessToken(false); + assertEquals("tokenX", token1); + assertEquals("tokenX", token2); + verify(harness.httpRequestMock, times(1)).send(); + } - String accessToken1 = confidentialClientSpy.getAccessToken(); - String accessToken2 = confidentialClientSpy.getAccessToken(); + @Test + void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception { + TestHarness harness = createClientWithTokens(899, "tokenA", "tokenB"); + String tokenA = harness.client.getAccessToken(true); // force fetch first (tokenA) + String tokenB = harness.client.getAccessToken(false); // should use cached tokenA, not fetch tokenB + assertEquals("tokenA", tokenA); + assertEquals("tokenA", tokenB); + verify(harness.httpRequestMock, times(1)).send(); + } - assertEquals("test token", accessToken1); - assertEquals("test token", accessToken2); - verify(httpRequestMock).send(); + private static class TestHarness { + final ConfidentialClient client; + final HTTPRequest httpRequestMock; + TestHarness(ConfidentialClient client, HTTPRequest httpRequestMock) { + this.client = client; + this.httpRequestMock = httpRequestMock; + } } - @Test - void getAccessTokenCallingBeforeAndAfterExpirationReturnsDifferentAccessToken() throws Exception { + private static TestHarness createClientWithTokens(int expiresInSeconds, String... tokens) throws Exception { HttpURLConnection mockedConn = mock(HttpURLConnection.class); URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); - Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( - mockedURL, "validConfig.txt" - ); - - HTTPResponse res1 = new HTTPResponse(HTTPResponse.SC_OK); - res1.setContent("{\"access_token\":\"test token 1\",\"token_type\":\"Bearer\",\"expires_in\":0}"); - res1.setHeader("Content-Type", "application/json;charset=utf-8"); - - HTTPResponse res2 = new HTTPResponse(HTTPResponse.SC_OK); - res2.setContent("{\"access_token\":\"test token 2\",\"token_type\":\"Bearer\",\"expires_in\":0}"); - res2.setHeader("Content-Type", "application/json;charset=utf-8"); + Configuration configurationMock = getConfigSpyMockedResponse(mockedURL, "validConfig.txt"); AuthorizationGrant grant = new UnitTestGrant(); URI uriSpy = spy(new URI("https://test.test.com/.test-test/test-test")); TokenRequest tokenRequestMock = spy(new TokenRequest(uriSpy, grant, new Scope())); - TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder()); - HTTPRequest httpRequestMock = mock(HTTPRequest.class); + OngoingStubbing stubbing = null; + for (String token : tokens) { + HTTPResponse res = new HTTPResponse(HTTPResponse.SC_OK); + String body = String.format("{\"access_token\":\"%s\",\"token_type\":\"Bearer\",\"expires_in\":%d}", token, expiresInSeconds); + res.setContent(body); + res.setHeader("Content-Type", "application/json;charset=utf-8"); + if (stubbing == null) { + stubbing = when(httpRequestMock.send()); + stubbing = stubbing.thenReturn(res); + } else { + stubbing = stubbing.thenReturn(res); + } + } + doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build(); doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest(); - doReturn(res1).doReturn(res2).when(httpRequestMock).send(); ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); - - String accessToken1 = confidentialClientSpy.getAccessToken(); - String accessToken2 = confidentialClientSpy.getAccessToken(); - - assertEquals("test token 1", accessToken1); - assertEquals("test token 2", accessToken2); - verify(httpRequestMock, times(2)).send(); - } - - @Test - void getAccessTokenCallingWithSendErrorRaisesAccessTokenException() throws Exception { - try { - HttpURLConnection mockedConn = mock(HttpURLConnection.class); - URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); - Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( - mockedURL, "validConfig.txt" - ); - - HTTPRequest mockedRequest = mock(HTTPRequest.class); - TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy( - HTTPResponse.SC_OK, - "{\"error_description\":\"Invalid request.\",\"error\":\"invalid_request\"}", - false, - mockedRequest - ); - - ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); - - confidentialClientSpy.getAccessToken(); - fail(); - } catch (AccessTokenException e) { - assertEquals("Error attempting to get the access token", e.getMessage()); - } + return new TestHarness(confidentialClientSpy, httpRequestMock); } private static URL getUrlMockResponse(String stringFile, HttpURLConnection mockedConn) throws IOException { From 837d3d9ba0d09ffe681a8d9f6770deacb35cb439 Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Tue, 30 Sep 2025 13:24:40 +0100 Subject: [PATCH 2/8] docs(java): update readme with forceRefresh --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 230bb58..11ad6fc 100644 --- a/README.md +++ b/README.md @@ -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. + ### 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: From d778c03ee8c7e0aff5fd0d22bb443d262bbf3681 Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Tue, 30 Sep 2025 23:23:25 +0100 Subject: [PATCH 3/8] fix(java): configurable proactive offset --- README.md | 20 +++++++ .../authentication/ConfidentialClient.java | 40 +++++++++++--- .../sdk/utils/authentication/Constants.java | 6 ++- .../ConfidentialClientTest.java | 52 +++++++++++++++++++ 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 11ad6fc..a3ebc20 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,26 @@ 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)` always forces a fresh token (bypasses cache). + +You can override the proactive offset by supplying a custom value (milliseconds) via the constructor overload. + +#### Example +```java +ConfidentialClient client10s = new ConfidentialClient( + "./path/to/config.json", + RequestOptions.builder().build(), + 10000L // 10 second proactive expiry offset +); +``` + ## Modules Information about the various utility modules contained in this library can be found below. diff --git a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java index cbe6b4e..9b4405a 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -51,6 +51,7 @@ public class ConfidentialClient implements OAuth2Client { private long jwsIssuedAt; private long accessTokenExpireTime; private AccessToken accessToken; + private final long accessTokenExpiryOffsetMillis; /** * 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 { public ConfidentialClient(final String configPath) throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath)); + this(new Configuration(configPath), RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); } /** @@ -84,7 +85,19 @@ public ConfidentialClient(final String configPath) public ConfidentialClient(final String configPath, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath), requestOptions); + this(new Configuration(configPath), requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + } + + /** + * Creates a new ConfidentialClient with a custom proactive expiry offset. + * @param configPath path to config file + * @param requestOptions request options (proxy/ssl) + * @param accessTokenExpiryOffsetMillis milliseconds subtracted from server expiry (non-negative) + */ + public ConfidentialClient(final String configPath, RequestOptions requestOptions, long accessTokenExpiryOffsetMillis) + throws AuthServerMetadataContentException, AuthServerMetadataException, + ConfigurationException { + this(new Configuration(configPath), requestOptions, accessTokenExpiryOffsetMillis); } /** @@ -99,7 +112,7 @@ public ConfidentialClient(final String configPath, RequestOptions requestOptions */ public ConfidentialClient(final Configuration config) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config, RequestOptions.builder().build()); + this(config, RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); } /** @@ -115,11 +128,22 @@ public ConfidentialClient(final Configuration config) */ public ConfidentialClient(final Configuration config, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException { + this(config, requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + } + + /** + * Core constructor with configurable access token proactive expiry offset. + * @param config configuration + * @param requestOptions request options + * @param accessTokenExpiryOffsetMillis milliseconds to subtract from token lifetime when computing internal expiry + */ + public ConfidentialClient(final Configuration config, RequestOptions requestOptions, long accessTokenExpiryOffsetMillis) + throws AuthServerMetadataContentException, AuthServerMetadataException { Objects.requireNonNull(config, "Configuration object must not be null"); this.config = config; LOGGER.debug("Finished initialising configuration"); this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions; - + this.accessTokenExpiryOffsetMillis = accessTokenExpiryOffsetMillis; this.requestProviderMetadata(); } @@ -139,7 +163,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath)); + this(new Configuration(configPath), RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -157,7 +181,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(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -176,7 +200,7 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config, requestOptions); + this(config, requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -283,7 +307,7 @@ 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()) - Constants.ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS; + this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()) - this.accessTokenExpiryOffsetMillis; LOGGER.info("Fetched access token which expires in: {} seconds (buffered)", this.accessToken.getLifetime()); return this.accessToken.toString(); } diff --git a/src/main/java/com/factset/sdk/utils/authentication/Constants.java b/src/main/java/com/factset/sdk/utils/authentication/Constants.java index 0b0b01d..413b1d5 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/Constants.java +++ b/src/main/java/com/factset/sdk/utils/authentication/Constants.java @@ -13,8 +13,10 @@ public final class Constants { // default values public static final String FACTSET_WELL_KNOWN_URI = "https://auth.factset.com/.well-known/openid-configuration"; - // Buffer (in milliseconds) to refresh access token before actual expiry - public static final long ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS = 30000; // 30 seconds + /** + * Default buffer (in milliseconds) to refresh access token before actual expiry. + */ + public static final long DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS = 30000L; private Constants() { throw new IllegalStateException("Utility class"); diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index b9ae831..907047e 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -336,6 +336,58 @@ void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception verify(harness.httpRequestMock, times(1)).send(); } + @Test + void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() throws Exception { + long offsetMillis = 50_000L; + TestHarness harness = createClientTokenCustomOffset(offsetMillis); + + String first = harness.client.getAccessToken(); + assertEquals("tokenSingle", first); + verify(harness.httpRequestMock, times(1)).send(); + + java.lang.reflect.Field issuedAtField = ConfidentialClient.class.getDeclaredField("jwsIssuedAt"); + java.lang.reflect.Field expiryField = ConfidentialClient.class.getDeclaredField("accessTokenExpireTime"); + issuedAtField.setAccessible(true); + expiryField.setAccessible(true); + long issuedAt = (long) issuedAtField.get(harness.client); + long internalExpiry = (long) expiryField.get(harness.client); + long expectedDelta = 899_000L - offsetMillis; + assertEquals(expectedDelta, internalExpiry - issuedAt, "Internal expiry should be lifetime - offset"); + + expiryField.set(harness.client, System.currentTimeMillis() - 1); + + String second = harness.client.getAccessToken(); + assertEquals("tokenSingle", second); + verify(harness.httpRequestMock, times(2)).send(); + } + + private static TestHarness createClientTokenCustomOffset(long offsetMillis) throws Exception { + HttpURLConnection mockedConn = mock(HttpURLConnection.class); + URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); + Configuration configurationMock = getConfigSpyMockedResponse(mockedURL, "validConfig.txt"); + + AuthorizationGrant grant = new UnitTestGrant(); + URI uriSpy = spy(new URI("https://test.test.com/.test-test/test-test")); + TokenRequest tokenRequestMock = spy(new TokenRequest(uriSpy, grant, new Scope())); + TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder()); + HTTPRequest httpRequestMock = mock(HTTPRequest.class); + + HTTPResponse res = new HTTPResponse(HTTPResponse.SC_OK); + res.setContent("{\"access_token\":\"tokenSingle\",\"token_type\":\"Bearer\",\"expires_in\":899}"); + res.setHeader("Content-Type", "application/json;charset=utf-8"); + + doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build(); + doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest(); + when(httpRequestMock.send()).thenReturn(res, res); + + ConfidentialClient client = new ConfidentialClient(configurationMock, RequestOptions.builder().build(), offsetMillis); + java.lang.reflect.Field f = ConfidentialClient.class.getDeclaredField("tokenRequestBuilder"); + f.setAccessible(true); + f.set(client, tokenRequestBuilderSpy); + + return new TestHarness(client, httpRequestMock); + } + private static class TestHarness { final ConfidentialClient client; final HTTPRequest httpRequestMock; From 89be8e10a16d9eb606a3e83decb2b66f50381a7a Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Wed, 1 Oct 2025 18:08:47 +0100 Subject: [PATCH 4/8] fix(java): offset via requestOptions --- README.md | 11 ++-- .../authentication/ConfidentialClient.java | 55 +++++++--------- .../sdk/utils/authentication/Constants.java | 5 -- .../utils/authentication/RequestOptions.java | 3 + .../ConfidentialClientTest.java | 62 ++++++++++++++++++- 5 files changed, 91 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index a3ebc20..34aa369 100644 --- a/README.md +++ b/README.md @@ -189,15 +189,14 @@ Default behaviour: - Calls to `getAccessToken()` (or `getAccessToken(false)`) reuse the cached token while it is still considered valid under this adjusted expiry. - `getAccessToken(true)` always forces a fresh token (bypasses cache). -You can override the proactive offset by supplying a custom value (milliseconds) via the constructor overload. +You can override the proactive offset by configuring it in `RequestOptions`: #### Example ```java -ConfidentialClient client10s = new ConfidentialClient( - "./path/to/config.json", - RequestOptions.builder().build(), - 10000L // 10 second proactive expiry offset -); +RequestOptions options10s = RequestOptions.builder() + .accessTokenExpiryOffsetMillis(90_000L) // 90 seconds + .build(); +ConfidentialClient client10s = new ConfidentialClient("./path/to/config.json", options10s); ``` ## Modules diff --git a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java index 9b4405a..3a4a281 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -67,7 +67,7 @@ public class ConfidentialClient implements OAuth2Client { public ConfidentialClient(final String configPath) throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath), RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + this(new Configuration(configPath), RequestOptions.builder().build()); } /** @@ -85,19 +85,7 @@ public ConfidentialClient(final String configPath) public ConfidentialClient(final String configPath, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath), requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); - } - - /** - * Creates a new ConfidentialClient with a custom proactive expiry offset. - * @param configPath path to config file - * @param requestOptions request options (proxy/ssl) - * @param accessTokenExpiryOffsetMillis milliseconds subtracted from server expiry (non-negative) - */ - public ConfidentialClient(final String configPath, RequestOptions requestOptions, long accessTokenExpiryOffsetMillis) - throws AuthServerMetadataContentException, AuthServerMetadataException, - ConfigurationException { - this(new Configuration(configPath), requestOptions, accessTokenExpiryOffsetMillis); + this(new Configuration(configPath), requestOptions); } /** @@ -112,7 +100,7 @@ public ConfidentialClient(final String configPath, RequestOptions requestOptions */ public ConfidentialClient(final Configuration config) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config, RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + this(config, RequestOptions.builder().build()); } /** @@ -128,22 +116,11 @@ public ConfidentialClient(final Configuration config) */ public ConfidentialClient(final Configuration config, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config, requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); - } - - /** - * Core constructor with configurable access token proactive expiry offset. - * @param config configuration - * @param requestOptions request options - * @param accessTokenExpiryOffsetMillis milliseconds to subtract from token lifetime when computing internal expiry - */ - public ConfidentialClient(final Configuration config, RequestOptions requestOptions, long accessTokenExpiryOffsetMillis) - throws AuthServerMetadataContentException, AuthServerMetadataException { Objects.requireNonNull(config, "Configuration object must not be null"); this.config = config; LOGGER.debug("Finished initialising configuration"); this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions; - this.accessTokenExpiryOffsetMillis = accessTokenExpiryOffsetMillis; + this.accessTokenExpiryOffsetMillis = this.requestOptions.getAccessTokenExpiryOffsetMillis(); this.requestProviderMetadata(); } @@ -163,7 +140,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath), RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + this(new Configuration(configPath), RequestOptions.builder().build()); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -181,7 +158,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config, RequestOptions.builder().build(), Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + this(config, RequestOptions.builder().build()); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -200,7 +177,7 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config, requestOptions, Constants.DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS); + this(config, requestOptions); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -306,9 +283,21 @@ 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()) - this.accessTokenExpiryOffsetMillis; - LOGGER.info("Fetched access token which expires in: {} seconds (buffered)", this.accessToken.getLifetime()); + long lifetimeMillis = TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()); + long rawOffset = this.accessTokenExpiryOffsetMillis; + + long clampedOffset; + if (rawOffset >= 899_000L) { + clampedOffset = 899_000L - 1; + LOGGER.warn("Proactive expiry offset {}ms >= 899 seconds. Clamped to {}ms.", rawOffset, clampedOffset); + } else { + clampedOffset = rawOffset; + } + + long effectiveLifetime = lifetimeMillis - clampedOffset; + this.accessTokenExpireTime = this.jwsIssuedAt + effectiveLifetime; + LOGGER.info("Fetched access token (serverLifetime={}s, offsetApplied={}ms, effectiveLifetime={}ms)", + this.accessToken.getLifetime(), clampedOffset, effectiveLifetime); return this.accessToken.toString(); } diff --git a/src/main/java/com/factset/sdk/utils/authentication/Constants.java b/src/main/java/com/factset/sdk/utils/authentication/Constants.java index 413b1d5..ed60b44 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/Constants.java +++ b/src/main/java/com/factset/sdk/utils/authentication/Constants.java @@ -13,11 +13,6 @@ public final class Constants { // default values public static final String FACTSET_WELL_KNOWN_URI = "https://auth.factset.com/.well-known/openid-configuration"; - /** - * Default buffer (in milliseconds) to refresh access token before actual expiry. - */ - public static final long DEFAULT_ACCESS_TOKEN_EXPIRY_OFFSET_MILLIS = 30000L; - private Constants() { throw new IllegalStateException("Utility class"); } diff --git a/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java b/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java index 174b6c4..37c8e25 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java +++ b/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java @@ -22,4 +22,7 @@ public class RequestOptions { @Builder.Default String userAgent = "fds-sdk/java/utils/1.1.5 (" + System.getProperty("os.name") + "; Java" + System.getProperty("java.version") + ")"; + + @Builder.Default + long accessTokenExpiryOffsetMillis = 30_000L; } diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index 907047e..d23bcf4 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -361,6 +361,62 @@ void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() th verify(harness.httpRequestMock, times(2)).send(); } + @Test + void accessTokenDefaultOffsetUsesThirtySeconds() throws Exception { + RequestOptions defaultOptions = RequestOptions.builder().build(); + TestHarness harness = createClientTokenCustomOffset(30_000L); + + String token = harness.client.getAccessToken(); + assertEquals("tokenSingle", token); + + assertEquals(30_000L, defaultOptions.getAccessTokenExpiryOffsetMillis(), "RequestOptions should have default 30s offset"); + + java.lang.reflect.Field issuedAtField = ConfidentialClient.class.getDeclaredField("jwsIssuedAt"); + java.lang.reflect.Field expiryField = ConfidentialClient.class.getDeclaredField("accessTokenExpireTime"); + issuedAtField.setAccessible(true); + expiryField.setAccessible(true); + + long issuedAt = (long) issuedAtField.get(harness.client); + long internalExpiry = (long) expiryField.get(harness.client); + long expectedDelta = 899_000L - 30_000L; + assertEquals(expectedDelta, internalExpiry - issuedAt, "Internal expiry should be lifetime - default offset"); + } + + @Test + void accessTokenNegativeOffsetExtendsLifetime() throws Exception { + TestHarness harness = createClientTokenCustomOffset(-10_000L); + + String first = harness.client.getAccessToken(); + assertEquals("tokenSingle", first); + verify(harness.httpRequestMock, times(1)).send(); + + java.lang.reflect.Field issuedAtField = ConfidentialClient.class.getDeclaredField("jwsIssuedAt"); + java.lang.reflect.Field expiryField = ConfidentialClient.class.getDeclaredField("accessTokenExpireTime"); + issuedAtField.setAccessible(true); + expiryField.setAccessible(true); + long issuedAt = (long) issuedAtField.get(harness.client); + long internalExpiry = (long) expiryField.get(harness.client); + long expectedDelta = 899_000L - (-10_000L); + assertEquals(expectedDelta, internalExpiry - issuedAt, "Negative offset should extend lifetime"); + } + + @Test + void accessTokenLargeOffsetGetsClampedToUnder899Seconds() throws Exception { + TestHarness harness = createClientTokenCustomOffset(900_000L); + + String first = harness.client.getAccessToken(); + assertEquals("tokenSingle", first); + + java.lang.reflect.Field issuedAtField = ConfidentialClient.class.getDeclaredField("jwsIssuedAt"); + java.lang.reflect.Field expiryField = ConfidentialClient.class.getDeclaredField("accessTokenExpireTime"); + issuedAtField.setAccessible(true); + expiryField.setAccessible(true); + long issuedAt = (long) issuedAtField.get(harness.client); + long internalExpiry = (long) expiryField.get(harness.client); + long expectedDelta = 899_000L - (899_000L - 1); + assertEquals(expectedDelta, internalExpiry - issuedAt, "Large offset should be clamped, leaving 1ms effective lifetime"); + } + private static TestHarness createClientTokenCustomOffset(long offsetMillis) throws Exception { HttpURLConnection mockedConn = mock(HttpURLConnection.class); URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); @@ -380,7 +436,11 @@ private static TestHarness createClientTokenCustomOffset(long offsetMillis) thro doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest(); when(httpRequestMock.send()).thenReturn(res, res); - ConfidentialClient client = new ConfidentialClient(configurationMock, RequestOptions.builder().build(), offsetMillis); + RequestOptions requestOptionsWithOffset = RequestOptions.builder() + .accessTokenExpiryOffsetMillis(offsetMillis) + .build(); + + ConfidentialClient client = new ConfidentialClient(configurationMock, requestOptionsWithOffset); java.lang.reflect.Field f = ConfidentialClient.class.getDeclaredField("tokenRequestBuilder"); f.setAccessible(true); f.set(client, tokenRequestBuilderSpy); From 7f70163bee2ea344e07d05ffb832180ebd73ba3d Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Fri, 10 Oct 2025 17:01:02 +0100 Subject: [PATCH 5/8] fix(java): thread reliability via sync --- README.md | 10 +- .../authentication/ConfidentialClient.java | 53 ++++---- .../utils/authentication/RequestOptions.java | 29 +++- .../ConfidentialClientTest.java | 124 +++++++++++------- 4 files changed, 141 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 34aa369..476c33f 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ 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. +> 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 @@ -187,16 +187,16 @@ The `ConfidentialClient` refreshes access tokens proactively before their actual 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)` always forces a fresh token (bypasses cache). +- `getAccessToken(true)` forces a fresh token unless one was very recently refreshed (within 5 seconds) to avoid unnecessary duplicate requests from concurrent threads. You can override the proactive offset by configuring it in `RequestOptions`: #### Example ```java -RequestOptions options10s = RequestOptions.builder() - .accessTokenExpiryOffsetMillis(90_000L) // 90 seconds +RequestOptions options = RequestOptions.builder() + .accessTokenExpiryOffset(Duration.ofSeconds(90)) // 90 seconds .build(); -ConfidentialClient client10s = new ConfidentialClient("./path/to/config.json", options10s); +ConfidentialClient client = new ConfidentialClient("./path/to/config.json", options); ``` ## Modules diff --git a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java index 3a4a281..6c129b7 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -33,6 +33,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.time.Duration; // added for internal duration usage import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,7 +52,8 @@ public class ConfidentialClient implements OAuth2Client { private long jwsIssuedAt; private long accessTokenExpireTime; private AccessToken accessToken; - private final long accessTokenExpiryOffsetMillis; + private final Duration accessTokenExpiryOffset; + private long lastRefreshTime; /** * Creates a new ConfidentialClient. When setting up the OAuth 2.0 client, this constructor reaches out to @@ -120,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.accessTokenExpiryOffsetMillis = this.requestOptions.getAccessTokenExpiryOffsetMillis(); + this.accessTokenExpiryOffset = this.requestOptions.getAccessTokenExpiryOffset(); this.requestProviderMetadata(); } @@ -184,18 +186,31 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild /** * 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, always fetches a new token regardless of cache. + * 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 (!forceRefresh && this.isCachedTokenValid()) { - LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis())); - return this.accessToken.toString(); + public synchronized 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(); + } + + long currentTime = System.currentTimeMillis(); + + // Implementing a grace period of 5 seconds to avoid unnecessary token refreshes + 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(); } @@ -210,7 +225,7 @@ public String getAccessToken(boolean forceRefresh) throws AccessTokenException, * @throws SigningJwsException If the signing of the JWS fails. */ @Override - public String getAccessToken() throws AccessTokenException, SigningJwsException { + public synchronized 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(); @@ -249,7 +264,7 @@ private void requestProviderMetadata() throws AuthServerMetadataContentException new TokenRequestBuilder().uri(this.providerMetadata.getTokenEndpointURI()); } - private boolean isCachedTokenValid() { + private synchronized boolean isCachedTokenValid() { if (this.accessToken == null) { return false; } @@ -283,21 +298,13 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti if (tokenRes.indicatesSuccess()) { this.accessToken = tokenRes.toSuccessResponse().getTokens().getAccessToken(); - long lifetimeMillis = TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()); - long rawOffset = this.accessTokenExpiryOffsetMillis; - - long clampedOffset; - if (rawOffset >= 899_000L) { - clampedOffset = 899_000L - 1; - LOGGER.warn("Proactive expiry offset {}ms >= 899 seconds. Clamped to {}ms.", rawOffset, clampedOffset); - } else { - clampedOffset = rawOffset; - } - - long effectiveLifetime = lifetimeMillis - clampedOffset; + 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, offsetApplied={}ms, effectiveLifetime={}ms)", - this.accessToken.getLifetime(), clampedOffset, 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(); } diff --git a/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java b/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java index 37c8e25..6a5247c 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java +++ b/src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java @@ -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 @@ -23,6 +26,30 @@ 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 - long accessTokenExpiryOffsetMillis = 30_000L; + 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); + } + }; + } } diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index d23bcf4..60a31e4 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -16,8 +16,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.List; -import java.util.Map; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -25,19 +29,19 @@ class ConfidentialClientTest { public final static String validJwk = "{\n" + - " \"p\": \"3QAUkyFNCv8CRLQfpj9zovNUchcN-HgCxOY_BMWPsbFzZ8slliFoQl8EANEJJPUMKY8sh3ZnU0pH2T8qoQoRvDstX4XzH0kdMKK8LMJ-8J5Nzf2Ps9Z2va_G0OhkMkdT__7jzO-qHQAgIxOy15ka4JGvqhi9fsB13RslsRNOpnk\",\n" + - " \"kty\": \"RSA\",\n" + - " \"q\": \"oBZ17ZrK2B5ufELRwc3ZLB09xo2LjuEK7k8ZTtM5FUBTn-6hoaJwwyJvI5UgxY5Ge46i_wQifMOJb3g-ALu8pq-Nm6N0HmZ9dxU8_REZEQFARM9pieU-dQxYJZFrbqWFLiVYc8kq8mocQe25TFmBI3t_TQ8Y7C2KltOKQTbnkAs\",\n" + - " \"d\": \"eeZ7uLCCq9Xzd6q0O13F38hfGEgajV_zMf893Bm-qjH3ipzwCztESeqaKJFNmZEkQ1a2ee2Rvjt0yZLF-8Fxu53TgfEipNWF03zraEhmM62wf86g1dFrAwFBJ0-HbPyQ_Z9zvD8y_XjrxNJ887bxHJmnFU1ER2AfW519mHm2zH8mU_tZQrhQ3f8bJSkg528LDSmStCXUPHKczxdCQj5Vg93mZQtHFG-r3h0AHWZKIidDqoFZTNuimrFL-BTAiT72GnFDhJTKpzGnWXeQ65e_0z0agh2hHYTNyKcTffWjRnNwH5q02VpHLHQ_I8GFGmhzdN4Mtg9tVQ_dpOiOiaw-UQ\",\n" + - " \"e\": \"AQAB\",\n" + - " \"use\": \"sig\",\n" + - " \"kid\": \"Pa-A4WppSTO39nfRFBP_IpM13sBNXnmj9liYF5pYRhI\",\n" + - " \"qi\": \"tBOoQVBu032Lkpnv5z5I4ynNhW8wD5o8DzMyH6OOeFujTz83plsk8zwZiKnSKcL2Qx9eUgmcLGMlx30lkyaw0nkHB7P6WDXqXsrS1c69ninzkzHd32-tQpqrOMT8vQKa0tawZjrIaEoR-3MhbMOXYrNCZvuixdJXz2E4KrJsFN0\",\n" + - " \"dp\": \"tbb-M-ga0CLUO6ebqnfb3i2Tzuez_gy3wizLvmGvgF03Vi3MbwBzGLfFs-ItUa0H3hgydgPee7bFExWEOLvtz0cdTMD4Ik5c6QO2FFusQq73rJuEEEwUgG3K3TVoRYsuv3xW1MhvqL7UreLhl7L1TZecyBDlpxYbE73hpRMKBYk\",\n" + - " \"alg\": \"RS256\",\n" + - " \"dq\": \"QzGqRhUW1yfO0DFrwaEZar7LUy_OSCaFZAmnYcKezyC0-Qg8p497LSyi4ZiSrNlPFEWGfOvLXfrlEPizbbNfN8ev9IfjEW-LchRkCQTINK8FvtwgPFUQpiiMRxiGs2aeRARA4Dir4hxPyAx0HmvjHHWVtU6E830aEryv5zeYcok\",\n" + - " \"n\": \"ijNwq-GQdu9yj1fpCLF3LJeKD_KxCFdVR6s4N57eNuhfZKGwQrnc_kf_1j7VLPCHx-UVI-S4A2yUKlo-G6h2otpQUtoN9WYaSIrowo2k7Fdd55zW1rtNzD_XplWLc8ZnBrGHLfWAQfMDHvhHsuPVctt3uH1aIv768iWahALra-ym0HHge_mluCD823Ovam-q_sn50ZCf58DbecZj7VGVCkzRNLDJsnSvh3w7BHDwUhw_oZls75IfZ-ORZQuykfEDvaHCrNbHaKJFK843m9v5C47BGqjTEqBOQ71XR3oZ-Znr1nlcE8k1FlkgA3VCFWFZuixEQJtg1tiKqbtGzzQ3Mw\"\n" + - "}"; + " \"p\": \"3QAUkyFNCv8CRLQfpj9zovNUchcN-HgCxOY_BMWPsbFzZ8slliFoQl8EANEJJPUMKY8sh3ZnU0pH2T8qoQoRvDstX4XzH0kdMKK8LMJ-8J5Nzf2Ps9Z2va_G0OhkMkdT__7jzO-qHQAgIxOy15ka4JGvqhi9fsB13RslsRNOpnk\",\n" + + " \"kty\": \"RSA\",\n" + + " \"q\": \"oBZ17ZrK2B5ufELRwc3ZLB09xo2LjuEK7k8ZTtM5FUBTn-6hoaJwwyJvI5UgxY5Ge46i_wQifMOJb3g-ALu8pq-Nm6N0HmZ9dxU8_REZEQFARM9pieU-dQxYJZFrbqWFLiVYc8kq8mocQe25TFmBI3t_TQ8Y7C2KltOKQTbnkAs\",\n" + + " \"d\": \"eeZ7uLCCq9Xzd6q0O13F38hfGEgajV_zMf893Bm-qjH3ipzwCztESeqaKJFNmZEkQ1a2ee2Rvjt0yZLF-8Fxu53TgfEipNWF03zraEhmM62wf86g1dFrAwFBJ0-HbPyQ_Z9zvD8y_XjrxNJ887bxHJmnFU1ER2AfW519mHm2zH8mU_tZQrhQ3f8bJSkg528LDSmStCXUPHKczxdCQj5Vg93mZQtHFG-r3h0AHWZKIidDqoFZTNuimrFL-BTAiT72GnFDhJTKpzGnWXeQ65e_0z0agh2hHYTNyKcTffWjRnNwH5q02VpHLHQ_I8GFGmhzdN4Mtg9tVQ_dpOiOiaw-UQ\",\n" + + " \"e\": \"AQAB\",\n" + + " \"use\": \"sig\",\n" + + " \"kid\": \"Pa-A4WppSTO39nfRFBP_IpM13sBNXnmj9liYF5pYRhI\",\n" + + " \"qi\": \"tBOoQVBu032Lkpnv5z5I4ynNhW8wD5o8DzMyH6OOeFujTz83plsk8zwZiKnSKcL2Qx9eUgmcLGMlx30lkyaw0nkHB7P6WDXqXsrS1c69ninzkzHd32-tQpqrOMT8vQKa0tawZjrIaEoR-3MhbMOXYrNCZvuixdJXz2E4KrJsFN0\",\n" + + " \"dp\": \"tbb-M-ga0CLUO6ebqnfb3i2Tzuez_gy3wizLvmGvgF03Vi3MbwBzGLfFs-ItUa0H3hgydgPee7bFExWEOLvtz0cdTMD4Ik5c6QO2FFusQq73rJuEEEwUgG3K3TVoRYsuv3xW1MhvqL7UreLhl7L1TZecyBDlpxYbE73hpRMKBYk\",\n" + + " \"alg\": \"RS256\",\n" + + " \"dq\": \"QzGqRhUW1yfO0DFrwaEZar7LUy_OSCaFZAmnYcKezyC0-Qg8p497LSyi4ZiSrNlPFEWGfOvLXfrlEPizbbNfN8ev9IfjEW-LchRkCQTINK8FvtwgPFUQpiiMRxiGs2aeRARA4Dir4hxPyAx0HmvjHHWVtU6E830aEryv5zeYcok\",\n" + + " \"n\": \"ijNwq-GQdu9yj1fpCLF3LJeKD_KxCFdVR6s4N57eNuhfZKGwQrnc_kf_1j7VLPCHx-UVI-S4A2yUKlo-G6h2otpQUtoN9WYaSIrowo2k7Fdd55zW1rtNzD_XplWLc8ZnBrGHLfWAQfMDHvhHsuPVctt3uH1aIv768iWahALra-ym0HHge_mluCD823Ovam-q_sn50ZCf58DbecZj7VGVCkzRNLDJsnSvh3w7BHDwUhw_oZls75IfZ-ORZQuykfEDvaHCrNbHaKJFK843m9v5C47BGqjTEqBOQ71XR3oZ-Znr1nlcE8k1FlkgA3VCFWFZuixEQJtg1tiKqbtGzzQ3Mw\"\n" + + "}"; private static Path pathToResources; @@ -127,9 +131,9 @@ void confidentialClientValidPathValidConfigCannotOpenConnectionThrowsAuthServerM void confidentialClientValidPathValidConfigCustomWellKnownUriThrowsConfigurationException() { try { Configuration configuration = new Configuration("testClientId", - "testAuthType", - RSAKey.parse(validJwk), - "failing:wellKnownUri//"); + "testAuthType", + RSAKey.parse(validJwk), + "failing:wellKnownUri//"); new ConfidentialClient(configuration); fail(); @@ -146,7 +150,7 @@ void confidentialClientValidPathValidConfigCannotGetInputStreamThrowsAuthServerM } catch (Exception e) { assertTrue(e instanceof AuthServerMetadataException); assertEquals(String.format("Error retrieving contents from WellKnownUri: %s", Constants.FACTSET_WELL_KNOWN_URI), - e.getMessage()); + e.getMessage()); } } @@ -162,9 +166,9 @@ void confidentialClientValidPathValidConfigMissingIssuerAndTokenEndpointThrowsAu void confidentialClientValidPathValidConfigCustomWellKnownUriInitialisesWithNoException() { assertDoesNotThrow(() -> { Configuration configuration = new Configuration("testClientId", - "testAuthType", - RSAKey.parse(validJwk), - "https://test.test.com/.test-test/test-test"); + "testAuthType", + RSAKey.parse(validJwk), + "https://test.test.com/.test-test/test-test"); // If this confidential client is instantiated without exceptions, that results in a passing test. HttpURLConnection mockedConn = mock(HttpURLConnection.class); @@ -306,16 +310,6 @@ void getAccessTokenCallingBeforeAndAfterExpirationReturnsDifferentAccessToken() verify(harness.httpRequestMock, times(2)).send(); } - @Test - void getAccessTokenWithForceRefreshTrueAlwaysFetchesNewToken() throws Exception { - TestHarness harness = createClientWithTokens(899, "token1", "token2"); - String tokenA = harness.client.getAccessToken(true); - String tokenB = harness.client.getAccessToken(true); - assertEquals("token1", tokenA); - assertEquals("token2", tokenB); - verify(harness.httpRequestMock, times(2)).send(); - } - @Test void getAccessTokenWithForceRefreshFalseReturnsCachedTokenIfValid() throws Exception { TestHarness harness = createClientWithTokens(899, "tokenX"); @@ -336,10 +330,46 @@ void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception verify(harness.httpRequestMock, times(1)).send(); } + @Test + void getAccessTokenTwoDifferentThreadsSimultaneouslyOnlyFetchesOnce() throws Exception { + TestHarness harness = createClientWithTokens(899, "threadedToken"); + + Runnable task = () -> { + String token; + try { + token = harness.client.getAccessToken(); + } catch (AccessTokenException | SigningJwsException e) { + throw new RuntimeException(e); + } + assertEquals("threadedToken", token); + }; + + Thread thread1 = new Thread(task); + Thread thread2 = new Thread(task); + thread1.start(); + thread2.start(); + thread1.join(); + thread2.join(); + + verify(harness.httpRequestMock, times(1)).send(); + } + + @Test + void forceRefreshWithinGracePeriodReturnsCachedToken() throws Exception { + TestHarness harness = createClientWithTokens(899, "token1", "token2", "token3"); + + String initialToken = harness.client.getAccessToken(); + assertEquals("token1", initialToken); + + String gracePeriodToken = harness.client.getAccessToken(true); + assertEquals("token1", gracePeriodToken); + + verify(harness.httpRequestMock, times(1)).send(); + } + @Test void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() throws Exception { - long offsetMillis = 50_000L; - TestHarness harness = createClientTokenCustomOffset(offsetMillis); + TestHarness harness = createClientTokenCustomOffset(50); String first = harness.client.getAccessToken(); assertEquals("tokenSingle", first); @@ -351,7 +381,7 @@ void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() th expiryField.setAccessible(true); long issuedAt = (long) issuedAtField.get(harness.client); long internalExpiry = (long) expiryField.get(harness.client); - long expectedDelta = 899_000L - offsetMillis; + long expectedDelta = 899_000L - 50_000L; assertEquals(expectedDelta, internalExpiry - issuedAt, "Internal expiry should be lifetime - offset"); expiryField.set(harness.client, System.currentTimeMillis() - 1); @@ -364,12 +394,12 @@ void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() th @Test void accessTokenDefaultOffsetUsesThirtySeconds() throws Exception { RequestOptions defaultOptions = RequestOptions.builder().build(); - TestHarness harness = createClientTokenCustomOffset(30_000L); + TestHarness harness = createClientTokenCustomOffset(30); String token = harness.client.getAccessToken(); assertEquals("tokenSingle", token); - assertEquals(30_000L, defaultOptions.getAccessTokenExpiryOffsetMillis(), "RequestOptions should have default 30s offset"); + assertEquals(30_000L, defaultOptions.getAccessTokenExpiryOffset().toMillis(), "RequestOptions should have default 30s offset"); java.lang.reflect.Field issuedAtField = ConfidentialClient.class.getDeclaredField("jwsIssuedAt"); java.lang.reflect.Field expiryField = ConfidentialClient.class.getDeclaredField("accessTokenExpireTime"); @@ -384,7 +414,7 @@ void accessTokenDefaultOffsetUsesThirtySeconds() throws Exception { @Test void accessTokenNegativeOffsetExtendsLifetime() throws Exception { - TestHarness harness = createClientTokenCustomOffset(-10_000L); + TestHarness harness = createClientTokenCustomOffset(-10); String first = harness.client.getAccessToken(); assertEquals("tokenSingle", first); @@ -401,8 +431,8 @@ void accessTokenNegativeOffsetExtendsLifetime() throws Exception { } @Test - void accessTokenLargeOffsetGetsClampedToUnder899Seconds() throws Exception { - TestHarness harness = createClientTokenCustomOffset(900_000L); + void accessTokenLargeOffsetGetsClampedToFiveSeconds() throws Exception { + TestHarness harness = createClientTokenCustomOffset(900); String first = harness.client.getAccessToken(); assertEquals("tokenSingle", first); @@ -413,11 +443,11 @@ void accessTokenLargeOffsetGetsClampedToUnder899Seconds() throws Exception { expiryField.setAccessible(true); long issuedAt = (long) issuedAtField.get(harness.client); long internalExpiry = (long) expiryField.get(harness.client); - long expectedDelta = 899_000L - (899_000L - 1); - assertEquals(expectedDelta, internalExpiry - issuedAt, "Large offset should be clamped, leaving 1ms effective lifetime"); + long expectedDelta = 899_000L - 894_000L; + assertEquals(expectedDelta, internalExpiry - issuedAt, "Large offset should be clamped, leaving 5s effective lifetime"); } - private static TestHarness createClientTokenCustomOffset(long offsetMillis) throws Exception { + private static TestHarness createClientTokenCustomOffset(int offset) throws Exception { HttpURLConnection mockedConn = mock(HttpURLConnection.class); URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); Configuration configurationMock = getConfigSpyMockedResponse(mockedURL, "validConfig.txt"); @@ -437,8 +467,8 @@ private static TestHarness createClientTokenCustomOffset(long offsetMillis) thro when(httpRequestMock.send()).thenReturn(res, res); RequestOptions requestOptionsWithOffset = RequestOptions.builder() - .accessTokenExpiryOffsetMillis(offsetMillis) - .build(); + .accessTokenExpiryOffset(Duration.ofSeconds(offset)) + .build(); ConfidentialClient client = new ConfidentialClient(configurationMock, requestOptionsWithOffset); java.lang.reflect.Field f = ConfidentialClient.class.getDeclaredField("tokenRequestBuilder"); @@ -451,6 +481,7 @@ private static TestHarness createClientTokenCustomOffset(long offsetMillis) thro private static class TestHarness { final ConfidentialClient client; final HTTPRequest httpRequestMock; + TestHarness(ConfidentialClient client, HTTPRequest httpRequestMock) { this.client = client; this.httpRequestMock = httpRequestMock; @@ -519,7 +550,7 @@ private static Configuration getConfigSpyThrowsIOException(String configFile) th private static TokenRequestBuilder createTokenRequestBuilderSpy(int statusCode, String resContent, boolean requiresHeader, HTTPRequest mockedRequest) throws URISyntaxException, - IOException { + IOException { HTTPResponse res = new HTTPResponse(statusCode); res.setContent(resContent); if (requiresHeader) { @@ -555,4 +586,5 @@ public Map> toParameters() { return null; } } + } From 9eb82dc7f75de8c3074959d420446ff84a7463e5 Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Wed, 15 Oct 2025 12:04:49 +0100 Subject: [PATCH 6/8] revert(java): synchronized function --- README.md | 2 +- .../authentication/ConfidentialClient.java | 18 +++++---------- .../ConfidentialClientTest.java | 23 ------------------- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 476c33f..0068615 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ The `ConfidentialClient` refreshes access tokens proactively before their actual 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 from concurrent threads. +- `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`: diff --git a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java index 6c129b7..9b4730c 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -33,7 +33,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; -import java.time.Duration; // added for internal duration usage +import java.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -194,16 +194,15 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild * @throws AccessTokenException If it can't make a successful request or parse the TokenRequest. * @throws SigningJwsException If the signing of the JWS fails. */ - public synchronized String getAccessToken(boolean forceRefresh) throws AccessTokenException, SigningJwsException { + 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(); - - // Implementing a grace period of 5 seconds to avoid unnecessary token refreshes boolean recentlyRefreshed = (currentTime - this.lastRefreshTime) < 5000; if (recentlyRefreshed) { LOGGER.debug("Force refresh requested but token was recently refreshed within grace period, returning cached token"); @@ -225,13 +224,8 @@ public synchronized String getAccessToken(boolean forceRefresh) throws AccessTok * @throws SigningJwsException If the signing of the JWS fails. */ @Override - public synchronized 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(); + public String getAccessToken() throws AccessTokenException, SigningJwsException { + return getAccessToken(false); } private void requestProviderMetadata() throws AuthServerMetadataContentException, AuthServerMetadataException { @@ -264,7 +258,7 @@ private void requestProviderMetadata() throws AuthServerMetadataContentException new TokenRequestBuilder().uri(this.providerMetadata.getTokenEndpointURI()); } - private synchronized boolean isCachedTokenValid() { + private boolean isCachedTokenValid() { if (this.accessToken == null) { return false; } diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index 60a31e4..45c5346 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -330,29 +330,6 @@ void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception verify(harness.httpRequestMock, times(1)).send(); } - @Test - void getAccessTokenTwoDifferentThreadsSimultaneouslyOnlyFetchesOnce() throws Exception { - TestHarness harness = createClientWithTokens(899, "threadedToken"); - - Runnable task = () -> { - String token; - try { - token = harness.client.getAccessToken(); - } catch (AccessTokenException | SigningJwsException e) { - throw new RuntimeException(e); - } - assertEquals("threadedToken", token); - }; - - Thread thread1 = new Thread(task); - Thread thread2 = new Thread(task); - thread1.start(); - thread2.start(); - thread1.join(); - thread2.join(); - - verify(harness.httpRequestMock, times(1)).send(); - } @Test void forceRefreshWithinGracePeriodReturnsCachedToken() throws Exception { From d89bab10a55990a905042dfca332ae48aa4312c4 Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Wed, 15 Oct 2025 13:28:51 +0100 Subject: [PATCH 7/8] fix: remove unused imports --- .../sdk/utils/authentication/ConfidentialClientTest.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index 45c5346..ea30e97 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -18,10 +18,6 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.*; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; From 93d5eec309114d18fdd5a00f586b0978bb023f77 Mon Sep 17 00:00:00 2001 From: Dhawal Gawande Date: Wed, 15 Oct 2025 13:35:25 +0100 Subject: [PATCH 8/8] feat(java): synchronize getAccessToken for threwad safety --- README.md | 2 +- .../authentication/ConfidentialClient.java | 2 +- .../ConfidentialClientTest.java | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0068615..476c33f 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ The `ConfidentialClient` refreshes access tokens proactively before their actual 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. +- `getAccessToken(true)` forces a fresh token unless one was very recently refreshed (within 5 seconds) to avoid unnecessary duplicate requests from concurrent threads. You can override the proactive offset by configuring it in `RequestOptions`: diff --git a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java index 9b4730c..fa72c43 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -194,7 +194,7 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild * @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 { + public synchronized 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())); diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java index ea30e97..0e55065 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -326,6 +326,31 @@ void getAccessTokenForceRefreshThenCachedReturnsCorrectTokens() throws Exception verify(harness.httpRequestMock, times(1)).send(); } + @Test + void getAccessTokenTwoDifferentThreadsSimultaneouslyOnlyFetchesOnce() throws Exception { + TestHarness harness = createClientWithTokens(899, "threadedToken"); + + Runnable task = () -> { + String token; + try { + token = harness.client.getAccessToken(); + } catch (AccessTokenException | SigningJwsException e) { + throw new RuntimeException(e); + } + assertEquals("threadedToken", token); + }; + + Thread thread1 = new Thread(task); + Thread thread2 = new Thread(task); + + thread1.start(); + thread2.start(); + + thread1.join(); + thread2.join(); + + verify(harness.httpRequestMock, times(1)).send(); + } @Test void forceRefreshWithinGracePeriodReturnsCachedToken() throws Exception {