diff --git a/README.md b/README.md index 230bb58..0068615 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. (See [Access Token Proactive Expiry Offset](#access-token-proactive-expiry-offset) for details on token expiry handling.) + ### Configure a Proxy The Confidential Client accepts an additional optional parameter called `RequestOptions`. This can be created to specify a proxy for the client to use. Below is an example of how to do this: @@ -178,6 +180,25 @@ RequestOptions reqOpt = RequestOptions.builder() .build(); ``` +### Access Token Proactive Expiry Offset + +The `ConfidentialClient` refreshes access tokens proactively before their actual server-declared expiry to reduce the risk of a token expiring mid-request (e.g. due to latency or clock skew). + +Default behaviour: +- A 30 second (30,000 ms) proactive offset is applied automatically. +- Calls to `getAccessToken()` (or `getAccessToken(false)`) reuse the cached token while it is still considered valid under this adjusted expiry. +- `getAccessToken(true)` forces a fresh token unless one was very recently refreshed (within 5 seconds) to avoid unnecessary duplicate requests. + +You can override the proactive offset by configuring it in `RequestOptions`: + +#### Example +```java +RequestOptions options = RequestOptions.builder() + .accessTokenExpiryOffset(Duration.ofSeconds(90)) // 90 seconds + .build(); +ConfidentialClient client = new ConfidentialClient("./path/to/config.json", options); +``` + ## Modules Information about the various utility modules contained in this library can be found below. 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..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,6 +33,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +52,8 @@ public class ConfidentialClient implements OAuth2Client { private long jwsIssuedAt; private long accessTokenExpireTime; private AccessToken accessToken; + private final Duration accessTokenExpiryOffset; + private long lastRefreshTime; /** * Creates a new ConfidentialClient. When setting up the OAuth 2.0 client, this constructor reaches out to @@ -66,7 +69,7 @@ public class ConfidentialClient implements OAuth2Client { public ConfidentialClient(final String configPath) throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath)); + this(new Configuration(configPath), RequestOptions.builder().build()); } /** @@ -119,7 +122,7 @@ public ConfidentialClient(final Configuration config, RequestOptions requestOpti this.config = config; LOGGER.debug("Finished initialising configuration"); this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions; - + this.accessTokenExpiryOffset = this.requestOptions.getAccessTokenExpiryOffset(); this.requestProviderMetadata(); } @@ -139,7 +142,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder throws AuthServerMetadataContentException, AuthServerMetadataException, ConfigurationException { - this(new Configuration(configPath)); + this(new Configuration(configPath), RequestOptions.builder().build()); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -157,7 +160,7 @@ protected ConfidentialClient(final String configPath, final TokenRequestBuilder protected ConfidentialClient(final Configuration config, final TokenRequestBuilder tokReqBuilder) throws AuthServerMetadataContentException, AuthServerMetadataException { - this(config); + this(config, RequestOptions.builder().build()); this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } @@ -180,6 +183,36 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild this.tokenRequestBuilder = tokReqBuilder.uri(this.providerMetadata.getTokenEndpointURI()); } + /** + * Returns an access token that can be used for authentication. If the cache contains a valid access token, + * it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server. + * If forceRefresh is true, fetches a new token unless one was very recently refreshed (within 5 seconds) + * to avoid unnecessary duplicate requests from concurrent threads. + * + * @param forceRefresh If true, forces fetching a new token from the server. + * @return The access token in string format. + * @throws AccessTokenException If it can't make a successful request or parse the TokenRequest. + * @throws SigningJwsException If the signing of the JWS fails. + */ + public String getAccessToken(boolean forceRefresh) throws AccessTokenException, SigningJwsException { + if (this.isCachedTokenValid()) { + if (!forceRefresh) { + LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis())); + return this.accessToken.toString(); + } + + // Implement a grace period of 5 seconds to avoid unnecessary token refreshes + long currentTime = System.currentTimeMillis(); + boolean recentlyRefreshed = (currentTime - this.lastRefreshTime) < 5000; + if (recentlyRefreshed) { + LOGGER.debug("Force refresh requested but token was recently refreshed within grace period, returning cached token"); + return this.accessToken.toString(); + } + } + + return this.fetchAccessToken(); + } + /** * Returns an access token that can be used for authentication. If the cache contains a valid access token, * it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server. The access @@ -192,12 +225,7 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild */ @Override public String getAccessToken() throws AccessTokenException, SigningJwsException { - if (this.isCachedTokenValid()) { - LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis())); - return this.accessToken.toString(); - } - - return this.fetchAccessToken(); + return getAccessToken(false); } private void requestProviderMetadata() throws AuthServerMetadataContentException, AuthServerMetadataException { @@ -264,9 +292,13 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti if (tokenRes.indicatesSuccess()) { this.accessToken = tokenRes.toSuccessResponse().getTokens().getAccessToken(); - this.accessTokenExpireTime = - this.jwsIssuedAt + TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()); - LOGGER.info("Fetched access token which expires in: {} seconds", this.accessToken.getLifetime()); + long lifetimeMillis = java.util.concurrent.TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime()); + long offsetMillis = this.accessTokenExpiryOffset.toMillis(); + long effectiveLifetime = lifetimeMillis - offsetMillis; + this.accessTokenExpireTime = this.jwsIssuedAt + effectiveLifetime; + LOGGER.info("Fetched access token (serverLifetime={}s, configuredOffset={}ms, effectiveLifetime={}ms)", + this.accessToken.getLifetime(), offsetMillis, effectiveLifetime); + this.lastRefreshTime = System.currentTimeMillis(); return this.accessToken.toString(); } 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..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 @@ -22,4 +25,31 @@ public class RequestOptions { @Builder.Default String userAgent = "fds-sdk/java/utils/1.1.5 (" + System.getProperty("os.name") + "; Java" + System.getProperty("java.version") + ")"; + + /** + * Maximum allowed proactive refresh offset (894 seconds). + */ + public static final Duration MAX_PROACTIVE_OFFSET = Duration.ofSeconds(894); + + private static final Logger LOG = LoggerFactory.getLogger(RequestOptions.class); + + @Builder.Default + Duration accessTokenExpiryOffset = Duration.ofSeconds(30); + + + public static RequestOptionsBuilder builder() { + return new RequestOptionsBuilder() { + + @Override + public RequestOptionsBuilder accessTokenExpiryOffset(Duration d) { + if (d == null) throw new IllegalArgumentException("accessTokenExpiryOffset cannot be null"); + if (d.compareTo(MAX_PROACTIVE_OFFSET) > 0) { + LOG.warn("Configured accessTokenExpiryOffset {} exceeds max {}; clamped to {}.", d, MAX_PROACTIVE_OFFSET, MAX_PROACTIVE_OFFSET); + return super.accessTokenExpiryOffset(MAX_PROACTIVE_OFFSET); + } + + return super.accessTokenExpiryOffset(d); + } + }; + } } 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..ea30e97 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; @@ -15,8 +16,8 @@ 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 static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -24,19 +25,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; @@ -126,9 +127,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(); @@ -145,7 +146,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()); } } @@ -161,9 +162,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); @@ -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,209 @@ 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" - ); + 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(); + } - 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"); + @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(); + } - 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())); + @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(); + } - TokenRequestBuilder tokenRequestBuilderSpy = spy(new TokenRequestBuilder()); + @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(); + } - HTTPRequest httpRequestMock = mock(HTTPRequest.class); - doReturn(tokenRequestMock).when(tokenRequestBuilderSpy).build(); - doReturn(httpRequestMock).when(tokenRequestMock).toHTTPRequest(); - doReturn(res).when(httpRequestMock).send(); + @Test + void forceRefreshWithinGracePeriodReturnsCachedToken() throws Exception { + TestHarness harness = createClientWithTokens(899, "token1", "token2", "token3"); - ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); + String initialToken = harness.client.getAccessToken(); + assertEquals("token1", initialToken); - String accessToken1 = confidentialClientSpy.getAccessToken(); - String accessToken2 = confidentialClientSpy.getAccessToken(); + String gracePeriodToken = harness.client.getAccessToken(true); + assertEquals("token1", gracePeriodToken); - assertEquals("test token", accessToken1); - assertEquals("test token", accessToken2); - verify(httpRequestMock).send(); + verify(harness.httpRequestMock, times(1)).send(); } @Test - void getAccessTokenCallingBeforeAndAfterExpirationReturnsDifferentAccessToken() throws Exception { - HttpURLConnection mockedConn = mock(HttpURLConnection.class); - URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); - Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( - mockedURL, "validConfig.txt" - ); + void accessTokenFiftySecondOffsetTriggersRefetchAfterEarlyExpirySingleToken() throws Exception { + TestHarness harness = createClientTokenCustomOffset(50); + + 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 - 50_000L; + 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(); + } - 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"); + @Test + void accessTokenDefaultOffsetUsesThirtySeconds() throws Exception { + RequestOptions defaultOptions = RequestOptions.builder().build(); + TestHarness harness = createClientTokenCustomOffset(30); - 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"); + String token = harness.client.getAccessToken(); + assertEquals("tokenSingle", token); + + 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"); + 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); + + 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 accessTokenLargeOffsetGetsClampedToFiveSeconds() throws Exception { + TestHarness harness = createClientTokenCustomOffset(900); + + 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 - 894_000L; + assertEquals(expectedDelta, internalExpiry - issuedAt, "Large offset should be clamped, leaving 5s effective lifetime"); + } + + 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"); 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(); - doReturn(res1).doReturn(res2).when(httpRequestMock).send(); + when(httpRequestMock.send()).thenReturn(res, res); - ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); + RequestOptions requestOptionsWithOffset = RequestOptions.builder() + .accessTokenExpiryOffset(Duration.ofSeconds(offset)) + .build(); - String accessToken1 = confidentialClientSpy.getAccessToken(); - String accessToken2 = confidentialClientSpy.getAccessToken(); + ConfidentialClient client = new ConfidentialClient(configurationMock, requestOptionsWithOffset); + java.lang.reflect.Field f = ConfidentialClient.class.getDeclaredField("tokenRequestBuilder"); + f.setAccessible(true); + f.set(client, tokenRequestBuilderSpy); - assertEquals("test token 1", accessToken1); - assertEquals("test token 2", accessToken2); - verify(httpRequestMock, times(2)).send(); + return new TestHarness(client, httpRequestMock); } - @Test - void getAccessTokenCallingWithSendErrorRaisesAccessTokenException() throws Exception { - try { - HttpURLConnection mockedConn = mock(HttpURLConnection.class); - URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); - Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( - mockedURL, "validConfig.txt" - ); + private static class TestHarness { + final ConfidentialClient client; + final HTTPRequest httpRequestMock; - HTTPRequest mockedRequest = mock(HTTPRequest.class); - TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy( - HTTPResponse.SC_OK, - "{\"error_description\":\"Invalid request.\",\"error\":\"invalid_request\"}", - false, - mockedRequest - ); + TestHarness(ConfidentialClient client, HTTPRequest httpRequestMock) { + this.client = client; + this.httpRequestMock = httpRequestMock; + } + } - ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); + private static TestHarness createClientWithTokens(int expiresInSeconds, String... tokens) throws Exception { + HttpURLConnection mockedConn = mock(HttpURLConnection.class); + URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); + Configuration configurationMock = getConfigSpyMockedResponse(mockedURL, "validConfig.txt"); - confidentialClientSpy.getAccessToken(); - fail(); - } catch (AccessTokenException e) { - assertEquals("Error attempting to get the access token", e.getMessage()); + 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(); + + ConfidentialClient confidentialClientSpy = spy(new ConfidentialClient(configurationMock, tokenRequestBuilderSpy)); + return new TestHarness(confidentialClientSpy, httpRequestMock); } private static URL getUrlMockResponse(String stringFile, HttpURLConnection mockedConn) throws IOException { @@ -427,7 +523,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) { @@ -463,4 +559,5 @@ public Map> toParameters() { return null; } } + }