From f0043340fd16bff517fff3244fe8f9533d3e3748 Mon Sep 17 00:00:00 2001 From: Granit Mullahasani Dula <156331405+gdulafactset@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:15:06 +0100 Subject: [PATCH] feat(scopes): supporting scopes in confidential client --- README.md | 42 +++++++ .../authentication/ConfidentialClient.java | 46 +++++++- .../utils/authentication/Configuration.java | 75 +++++++++++- .../utils/authentication/RequestOptions.java | 8 ++ .../authentication/TokenRequestBuilder.java | 19 ++- .../authentication/ConfidentialClientIT.java | 54 +++++++++ .../ConfidentialClientTest.java | 109 ++++++++++++++++++ .../authentication/ConfigurationTest.java | 88 ++++++++++++++ src/test/resources/invalidScopesNotArray.json | 23 ++++ src/test/resources/validConfigBlankScope.json | 26 +++++ src/test/resources/validConfigWithScopes.json | 26 +++++ 11 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 src/test/resources/invalidScopesNotArray.json create mode 100644 src/test/resources/validConfigBlankScope.json create mode 100644 src/test/resources/validConfigWithScopes.json diff --git a/README.md b/README.md index 873189c..c13dbdd 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,47 @@ RequestOptions reqOpt = RequestOptions.builder() .build(); ``` +### OAuth 2.0 Scopes + +[Scopes](https://github.com/factset/oauth2-guidelines#client-credentials-flow-1) narrow the access an access token +grants. They are optional: when no scopes are configured, the authorization server grants the client's default +registered scopes. + +Scopes can be set in the configuration file, using the optional `scopes` key: + +```json +{ + "clientId": "OAuth 2.0 Client ID registered with FactSet's Developer Portal", + "clientAuthType": "Confidential", + "scopes": ["https://api.factset.com/analytics/lookups.pa.readonly"], + "jwk": { } +} +``` + +They can also be set programmatically, either on the `Configuration`: + +```java +Configuration conf = new Configuration( + "client id", + "Confidential", + jwk, + null, // use FactSet's default well known URI + Arrays.asList("https://api.factset.com/analytics/lookups.pa.readonly")); +``` + +or on the `RequestOptions`, which is useful for narrowing a token without editing the configuration file: + +```java +RequestOptions requestOptions = RequestOptions.builder() + .scopes(Arrays.asList("https://api.factset.com/analytics/lookups.pa.readonly")) + .build(); + +ConfidentialClient confidentialClient = new ConfidentialClient("./path/to/config.json", requestOptions); +``` + +When scopes are set in both places, the ones on `RequestOptions` replace (rather than merge with) the ones on the +`Configuration`. Scopes are read once, when the `ConfidentialClient` is created. + ## Modules Information about the various utility modules contained in this library can be found below. @@ -203,6 +244,7 @@ Classes in the authentication module require OAuth 2.0 client configuration info "clientId": "OAuth 2.0 Client ID registered with FactSet's Developer Portal", "clientAuthType": "Confidential", "owners": ["USERNAME-SERIAL"], + "scopes": ["Optional list of OAuth 2.0 scopes to request"], "jwk": { "kty": "RSA", "use": "sig", 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..84545dd 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java +++ b/src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java @@ -14,6 +14,7 @@ import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.Scope; import com.nimbusds.oauth2.sdk.TokenRequest; import com.nimbusds.oauth2.sdk.TokenResponse; import com.nimbusds.oauth2.sdk.auth.JWTAuthenticationClaimsSet; @@ -47,6 +48,7 @@ public class ConfidentialClient implements OAuth2Client { private final Configuration config; private OIDCProviderMetadata providerMetadata; private final RequestOptions requestOptions; + private final Scope scope; private TokenRequestBuilder tokenRequestBuilder; private long jwsIssuedAt; private long accessTokenExpireTime; @@ -108,10 +110,12 @@ public ConfidentialClient(final Configuration config) * information about the OAuth 2.0 client is stored and used whenever a new access token is fetched. * * @param config Configuration object. - * @param requestOptions Object that can configure options like proxy and SSL settings + * @param requestOptions Object that can configure options like proxy and SSL settings. Any scopes set here + * replace the scopes configured on the Configuration. * @throws AuthServerMetadataContentException If Meta Issuer or Meta Token Endpoint is missing. * @throws AuthServerMetadataException If reading from URL is unsuccessful. * @throws NullPointerException Unchecked exception, if config is null. + * @throws IllegalArgumentException Unchecked exception, if any scope is null or empty. */ public ConfidentialClient(final Configuration config, RequestOptions requestOptions) throws AuthServerMetadataContentException, AuthServerMetadataException { @@ -120,6 +124,9 @@ public ConfidentialClient(final Configuration config, RequestOptions requestOpti LOGGER.debug("Finished initialising configuration"); this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions; + // Resolved before the well known URI round trip so an invalid scope fails fast. + this.scope = resolveScope(this.config, this.requestOptions); + this.requestProviderMetadata(); } @@ -230,6 +237,38 @@ private void requestProviderMetadata() throws AuthServerMetadataContentException new TokenRequestBuilder().uri(this.providerMetadata.getTokenEndpointURI()); } + /** + * Resolves the scopes to request, preferring those set on the RequestOptions over those on the Configuration. + * RequestOptions scopes replace rather than merge with the Configuration ones, so a client can narrow the token + * without editing the configuration file. + */ + private static Scope resolveScope(final Configuration config, final RequestOptions options) { + // Lombok skips @Builder.Default when the setter is called explicitly, so scopes(null) leaves a null here. + final List optionScopes = options.getScopes(); + final List values = + optionScopes != null && !optionScopes.isEmpty() ? optionScopes : config.getScopes(); + + final Scope resolved = new Scope(); + if (values == null) { + return resolved; + } + + for (final String value : values) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("scopes can not contain null or empty values"); + } + resolved.add(value.trim()); + } + + if (!resolved.isEmpty()) { + LOGGER.debug("Requesting access token with scopes: {}", resolved); + } + + return resolved; + } + + // The cache holds a single token because the scopes are fixed for the lifetime of this client. Allowing + // per-call scopes would require keying this cache by scope set. private boolean isCachedTokenValid() { if (this.accessToken == null) { return false; @@ -244,7 +283,10 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti final TokenResponse tokenRes; try { final SignedJWT signedJwt = this.getSignedJwt(); - final TokenRequest tokenRequest = this.tokenRequestBuilder.signedJwt(signedJwt).build(); + // Applied here rather than on builder creation because the protected constructors replace + // tokenRequestBuilder after requestProviderMetadata() has run. + final TokenRequest tokenRequest = + this.tokenRequestBuilder.signedJwt(signedJwt).scope(this.scope).build(); final HTTPRequest httpRequest = tokenRequest.toHTTPRequest(); httpRequest.setProxy(this.requestOptions.getProxy()); diff --git a/src/main/java/com/factset/sdk/utils/authentication/Configuration.java b/src/main/java/com/factset/sdk/utils/authentication/Configuration.java index f100f96..5c7a6cf 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/Configuration.java +++ b/src/main/java/com/factset/sdk/utils/authentication/Configuration.java @@ -2,12 +2,16 @@ import com.factset.sdk.utils.exceptions.ConfigurationException; import com.nimbusds.jose.jwk.RSAKey; +import org.json.JSONArray; import org.json.JSONObject; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; /** * Provides an instance of a validated configuration to be used for creating JWTs. @@ -18,6 +22,7 @@ public class Configuration { private final String clientAuthType; private final URL wellKnownUrl; private final RSAKey jwk; + private final List scopes; /** * Creates a valid Configuration instance containing data needed to create a JWT. @@ -41,7 +46,8 @@ public Configuration(final String clientId, * @param clientId The Client ID registered with FactSet:Developer. * @param clientAuthType The ClientAuthType. * @param jwk The JWK key. - * @param wellKnownUri Custom WellKnownUri to retrieve metadata from, about its authorization server. + * @param wellKnownUri Custom WellKnownUri to retrieve metadata from, about its authorization server. When null, + * FactSet's default well known URI is used. * @throws ConfigurationException If JWK required keys are missing from the RSA or any keys with a value that is * null or an empty string. * @throws IllegalArgumentException Unchecked exception, if clientID or clientAuthType is null or empty. @@ -50,12 +56,36 @@ public Configuration(final String clientId, final String clientAuthType, final RSAKey jwk, final String wellKnownUri) throws ConfigurationException { + this(clientId, clientAuthType, jwk, wellKnownUri, Collections.emptyList()); + } + + /** + * Creates a valid Configuration instance containing data needed to create a JWT. + * + * @param clientId The Client ID registered with FactSet:Developer. + * @param clientAuthType The ClientAuthType. + * @param jwk The JWK key. + * @param wellKnownUri Custom WellKnownUri to retrieve metadata from, about its authorization server. When null, + * FactSet's default well known URI is used. + * @param scopes The OAuth 2.0 scopes to request when fetching an access token. Optional; when null or + * empty, the client's default registered scopes are used. + * @throws ConfigurationException If JWK required keys are missing from the RSA or any keys with a value that is + * null or an empty string. + * @throws IllegalArgumentException Unchecked exception, if clientID or clientAuthType is null or empty, or if any + * scope is null or empty. + */ + public Configuration(final String clientId, + final String clientAuthType, + final RSAKey jwk, + final String wellKnownUri, + final List scopes) throws ConfigurationException { this.clientId = clientId; this.clientAuthType = clientAuthType; this.jwk = jwk; + this.scopes = copyScopes(scopes); try { - this.wellKnownUrl = new URL(wellKnownUri); + this.wellKnownUrl = new URL(wellKnownUri == null ? Constants.FACTSET_WELL_KNOWN_URI : wellKnownUri); } catch (final MalformedURLException e) { throw new ConfigurationException("Invalid well known URI", e); } @@ -80,6 +110,7 @@ public Configuration(final String configPath) throws ConfigurationException { this.clientAuthType = jsonObject.getString("clientAuthType"); this.jwk = RSAKey.parse(jsonObject.getJSONObject("jwk").toString()); this.wellKnownUrl = new URL(jsonObject.optString("wellKnownUri", Constants.FACTSET_WELL_KNOWN_URI)); + this.scopes = parseScopes(jsonObject); } catch (final Exception e) { throw new ConfigurationException("Exception caught when retrieving contents from file", e); } @@ -123,6 +154,40 @@ public RSAKey getJwk() { return this.jwk; } + /** + * The OAuth 2.0 scopes to request when fetching an access token. When empty, the client's default registered + * scopes are used. + * + * @return An unmodifiable list of scopes, never null. + */ + public List getScopes() { + return this.scopes; + } + + private static List parseScopes(final JSONObject jsonObject) { + if (!jsonObject.has("scopes")) { + return Collections.emptyList(); + } + + // getJSONArray/getString (rather than the opt* variants) so a malformed "scopes" value fails loudly instead + // of silently requesting no scopes. + final JSONArray scopesJson = jsonObject.getJSONArray("scopes"); + final List parsedScopes = new ArrayList(scopesJson.length()); + for (int i = 0; i < scopesJson.length(); i++) { + parsedScopes.add(scopesJson.getString(i)); + } + + return Collections.unmodifiableList(parsedScopes); + } + + private static List copyScopes(final List scopes) { + if (scopes == null || scopes.isEmpty()) { + return Collections.emptyList(); + } + + return Collections.unmodifiableList(new ArrayList(scopes)); + } + private void checkConfig() throws ConfigurationException { if (this.clientId == null || this.clientId.isEmpty()) { throw new IllegalArgumentException("clientId can not be null or empty"); @@ -135,5 +200,11 @@ private void checkConfig() throws ConfigurationException { if (this.jwk == null || !this.jwk.isPrivate()) { throw new ConfigurationException("JWK can not be null or have missing private key"); } + + for (final String scope : this.scopes) { + if (scope == null || scope.trim().isEmpty()) { + throw new IllegalArgumentException("scopes can not contain null or empty values"); + } + } } } 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 f9d871f..43b7c2d 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,8 @@ import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; import java.net.Proxy; +import java.util.Collections; +import java.util.List; @Value @Builder @@ -22,4 +24,10 @@ public class RequestOptions { @Builder.Default String userAgent = "fds-sdk/java/utils/1.1.6 (" + System.getProperty("os.name") + "; Java" + System.getProperty("java.version") + ")"; + + /** + * When set and non-empty, these replace (rather than merge with) any scopes configured on the {@link Configuration}. + */ + @Builder.Default + List scopes = Collections.emptyList(); } diff --git a/src/main/java/com/factset/sdk/utils/authentication/TokenRequestBuilder.java b/src/main/java/com/factset/sdk/utils/authentication/TokenRequestBuilder.java index bc024bd..56158b5 100644 --- a/src/main/java/com/factset/sdk/utils/authentication/TokenRequestBuilder.java +++ b/src/main/java/com/factset/sdk/utils/authentication/TokenRequestBuilder.java @@ -14,6 +14,7 @@ public class TokenRequestBuilder { private URI uri; private SignedJWT signedJwt; + private Scope scope = new Scope(); /** * Initialises the TokenRequestBuilder instance. @@ -46,8 +47,20 @@ public TokenRequestBuilder signedJwt(final SignedJWT signedJwtParam) { } /** - * Creates and returns an instance of the TokenRequest with a ClientCredentialsGrant and the specified URI and - * SignedJWT. + * Updates the scope field and returns the updated builder. An empty scope means no scope parameter is sent, in + * which case the authorization server grants the client's default registered scopes. + * + * @param scopeParam The Scope instance, null is treated as an empty scope. + * @return The TokenRequestBuilder. + */ + public TokenRequestBuilder scope(final Scope scopeParam) { + this.scope = scopeParam == null ? new Scope() : scopeParam; + return this; + } + + /** + * Creates and returns an instance of the TokenRequest with a ClientCredentialsGrant and the specified URI, + * SignedJWT and Scope. * * @return The TokenRequest instance. */ @@ -56,7 +69,7 @@ public TokenRequest build() { this.uri, new PrivateKeyJWT(this.signedJwt), new ClientCredentialsGrant(), - new Scope() + this.scope ); } } diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientIT.java b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientIT.java index e29725c..16d81fe 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientIT.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientIT.java @@ -16,6 +16,7 @@ import org.slf4j.LoggerFactory; import java.nio.file.Paths; +import java.util.Arrays; import static com.github.tomakehurst.wiremock.client.WireMock.*; import static java.nio.file.Files.readAllBytes; @@ -71,6 +72,59 @@ void logs_request_and_response_for_error() .haveAtLeastOne(matching(startsWith("TRACE:Token Response: 400 Bad Request"))); } + @Test + void sends_scope_parameter_when_scopes_configured(WireMockRuntimeInfo wm) throws Exception + { + stubFor(post("/as/token.oauth2").willReturn(okJson( + "{\"access_token\":\"xxx_access_token\",\"token_type\":\"Bearer\",\"expires_in\":1234}"))); + + Configuration scopedConfig = new Configuration( + "testClientId", + "testAuthType", + RSAKey.parse(ConfidentialClientTest.validJwk), + String.format("http://localhost:%d/well-known", wm.getHttpPort()), + Arrays.asList("factset.api.a", "factset.api.b")); + + new ConfidentialClient(scopedConfig).getAccessToken(); + + // Scope values are space delimited, and URLEncoder renders that space as '+' in the form body. + verify(postRequestedFor(urlEqualTo("/as/token.oauth2")) + .withRequestBody(containing("scope=factset.api.a+factset.api.b"))); + } + + @Test + void sends_scope_parameter_from_request_options(WireMockRuntimeInfo wm) throws Exception + { + stubFor(post("/as/token.oauth2").willReturn(okJson( + "{\"access_token\":\"xxx_access_token\",\"token_type\":\"Bearer\",\"expires_in\":1234}"))); + + Configuration scopedConfig = new Configuration( + "testClientId", + "testAuthType", + RSAKey.parse(ConfidentialClientTest.validJwk), + String.format("http://localhost:%d/well-known", wm.getHttpPort()), + Arrays.asList("factset.api.a")); + + RequestOptions reqOptions = RequestOptions.builder().scopes(Arrays.asList("factset.api.c")).build(); + + new ConfidentialClient(scopedConfig, reqOptions).getAccessToken(); + + verify(postRequestedFor(urlEqualTo("/as/token.oauth2")) + .withRequestBody(containing("scope=factset.api.c"))); + } + + @Test + void omits_scope_parameter_when_no_scopes_configured() throws Exception + { + stubFor(post("/as/token.oauth2").willReturn(okJson( + "{\"access_token\":\"xxx_access_token\",\"token_type\":\"Bearer\",\"expires_in\":1234}"))); + + new ConfidentialClient(configuration).getAccessToken(); + + verify(postRequestedFor(urlEqualTo("/as/token.oauth2")) + .withRequestBody(notMatching("(?s).*scope=.*"))); + } + private static ListAssert assertThatLogs(ThrowingRunnable test) { // get a handle to the underlying logger 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..3fb5ca5 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfidentialClientTest.java @@ -15,6 +15,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -463,4 +464,112 @@ public Map> toParameters() { return null; } } + + @Test + void getAccessTokenWithConfigScopesSendsScopeOnTokenRequest() throws Exception { + HttpURLConnection mockedConn = mock(HttpURLConnection.class); + URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); + Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( + mockedURL, "validConfigWithScopes.json" + ); + + HTTPRequest mockedRequest = mock(HTTPRequest.class); + TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy( + HTTPResponse.SC_OK, + "{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}", + true, + mockedRequest + ); + + new ConfidentialClient(configurationMock, tokenRequestBuilderSpy).getAccessToken(); + + verify(tokenRequestBuilderSpy).scope(new Scope("factset.api.a", "factset.api.b")); + } + + @Test + void getAccessTokenWithoutScopesSendsEmptyScopeOnTokenRequest() 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 + ); + + new ConfidentialClient(configurationMock, tokenRequestBuilderSpy).getAccessToken(); + + verify(tokenRequestBuilderSpy).scope(new Scope()); + } + + @Test + void getAccessTokenWithRequestOptionsScopesOverridesConfigScopes() throws Exception { + HttpURLConnection mockedConn = mock(HttpURLConnection.class); + URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); + Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( + mockedURL, "validConfigWithScopes.json" + ); + + HTTPRequest mockedRequest = mock(HTTPRequest.class); + TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy( + HTTPResponse.SC_OK, + "{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}", + true, + mockedRequest + ); + + RequestOptions reqOptions = RequestOptions.builder().scopes(Arrays.asList("factset.api.c")).build(); + + new ConfidentialClient(configurationMock, tokenRequestBuilderSpy, reqOptions).getAccessToken(); + + verify(tokenRequestBuilderSpy).scope(new Scope("factset.api.c")); + } + + @Test + void confidentialClientWithEmptyRequestOptionsScopesFallsBackToConfigScopes() throws Exception { + HttpURLConnection mockedConn = mock(HttpURLConnection.class); + URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); + Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( + mockedURL, "validConfigWithScopes.json" + ); + + HTTPRequest mockedRequest = mock(HTTPRequest.class); + TokenRequestBuilder tokenRequestBuilderSpy = ConfidentialClientTest.createTokenRequestBuilderSpy( + HTTPResponse.SC_OK, + "{\"access_token\":\"test token\",\"token_type\":\"Bearer\",\"expires_in\":899}", + true, + mockedRequest + ); + + // Explicitly setting null bypasses Lombok's @Builder.Default, so this also covers the null case. + RequestOptions reqOptions = RequestOptions.builder().scopes(null).build(); + + new ConfidentialClient(configurationMock, tokenRequestBuilderSpy, reqOptions).getAccessToken(); + + verify(tokenRequestBuilderSpy).scope(new Scope("factset.api.a", "factset.api.b")); + } + + @Test + void confidentialClientWithBlankScopeInRequestOptionsThrowsIllegalArgumentException() throws Exception { + HttpURLConnection mockedConn = mock(HttpURLConnection.class); + URL mockedURL = getUrlMockResponse("exampleResponseWellKnownUri.txt", mockedConn); + Configuration configurationMock = ConfidentialClientTest.getConfigSpyMockedResponse( + mockedURL, "validConfig.txt" + ); + + RequestOptions reqOptions = RequestOptions.builder().scopes(Arrays.asList("factset.api.a", " ")).build(); + + try { + new ConfidentialClient(configurationMock, reqOptions); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IllegalArgumentException); + assertEquals("scopes can not contain null or empty values", e.getMessage()); + } + } } diff --git a/src/test/java/com/factset/sdk/utils/authentication/ConfigurationTest.java b/src/test/java/com/factset/sdk/utils/authentication/ConfigurationTest.java index 059bb7f..ff85eb2 100644 --- a/src/test/java/com/factset/sdk/utils/authentication/ConfigurationTest.java +++ b/src/test/java/com/factset/sdk/utils/authentication/ConfigurationTest.java @@ -1,6 +1,7 @@ package com.factset.sdk.utils.authentication; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -10,6 +11,8 @@ import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -181,4 +184,89 @@ void configurationValidConfigPathValidConfigInstantiatesConfiguration() { fail(); } } + + @Test + void configurationConfigPathWithScopesParsesScopes() throws Exception { + Configuration configuration = new Configuration(String.valueOf(Paths.get(String.valueOf(pathToResources), + "validConfigWithScopes.json"))); + + assertEquals(Arrays.asList("factset.api.a", "factset.api.b"), configuration.getScopes()); + } + + @Test + void configurationConfigPathWithoutScopesDefaultsToEmptyList() throws Exception { + Configuration configuration = new Configuration(String.valueOf(Paths.get(String.valueOf(pathToResources), + "validConfigStructure.txt"))); + + assertNotNull(configuration.getScopes()); + assertTrue(configuration.getScopes().isEmpty()); + } + + @Test + void configurationConfigPathWithBlankScopeThrowsIllegalArgumentException() { + try { + new Configuration(String.valueOf(Paths.get(String.valueOf(pathToResources), + "validConfigBlankScope.json"))); + fail(); + } catch (Exception e) { + assertTrue(e instanceof IllegalArgumentException); + assertEquals("scopes can not contain null or empty values", e.getMessage()); + } + } + + @Test + void configurationConfigPathWithScopesNotAnArrayThrowsConfigurationException() { + assertThrows(ConfigurationException.class, () -> new Configuration( + String.valueOf(Paths.get(String.valueOf(pathToResources), "invalidScopesNotArray.json")))); + } + + @Test + void configurationPassScopesStoresScopes() throws Exception { + Configuration configuration = new Configuration("test", "test", RSAKey.parse(validJwk), null, + Arrays.asList("factset.api.a", "factset.api.b")); + + assertEquals(Arrays.asList("factset.api.a", "factset.api.b"), configuration.getScopes()); + assertEquals(new URL(Constants.FACTSET_WELL_KNOWN_URI), configuration.getWellKnownUrl()); + } + + @Test + void configurationPassNullScopesDefaultsToEmptyList() throws Exception { + Configuration configuration = new Configuration("test", "test", RSAKey.parse(validJwk), null, null); + + assertNotNull(configuration.getScopes()); + assertTrue(configuration.getScopes().isEmpty()); + } + + @Test + void configurationPassBlankScopeThrowsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> new Configuration("test", "test", + RSAKey.parse(validJwk), null, Arrays.asList("factset.api.a", " "))); + } + + @Test + void configurationNotPassingScopesDefaultsToEmptyList() throws Exception { + Configuration configuration = new Configuration("test", "test", RSAKey.parse(validJwk)); + + assertNotNull(configuration.getScopes()); + assertTrue(configuration.getScopes().isEmpty()); + } + + @Test + void configurationGetScopesReturnsUnmodifiableList() throws Exception { + Configuration configuration = new Configuration("test", "test", RSAKey.parse(validJwk), null, + Arrays.asList("factset.api.a")); + + List scopes = configuration.getScopes(); + assertThrows(UnsupportedOperationException.class, () -> scopes.add("factset.api.b")); + } + + @Test + void configurationScopesAreSnapshottedFromTheSuppliedList() throws Exception { + List supplied = new java.util.ArrayList<>(Arrays.asList("factset.api.a")); + Configuration configuration = new Configuration("test", "test", RSAKey.parse(validJwk), null, supplied); + + supplied.add("factset.api.b"); + + assertEquals(Arrays.asList("factset.api.a"), configuration.getScopes()); + } } diff --git a/src/test/resources/invalidScopesNotArray.json b/src/test/resources/invalidScopesNotArray.json new file mode 100644 index 0000000..1c65132 --- /dev/null +++ b/src/test/resources/invalidScopesNotArray.json @@ -0,0 +1,23 @@ +{ + "name": "testName", + "clientId": "testClientId", + "clientAuthType": "testAuthType", + "owners": [ + "testOwners" + ], + "scopes": "factset.api.a factset.api.b", + "jwk": { + "p": "-y0YLv7K6cvmu9sJSyXJbjHQgqpJMkqWF8El5MSCnUpFvFd-qHDBXNGJfbol-bcomfVxiuUAxSDnyARNzoo_nDXeEDSu_4hZ82EcHeU5Mqb9hpmiy9QeMUPO__l-PIfMOH4d1ih6O84ARIpHLOx5kSfADesDyM5NgjFcbyN9CYM", + "kty": "RSA", + "q": "zNBNdbcN4aIBMw7YHGRr2Ype3lLckyFPozPgb11gs3bYTryE9A2to3Ik9Wu6rNF9V6pemf6j1jEGpX_z3Gx4EW9deY69-XdHch4ZqrSgEW1o2iykwwD1C4ocwZDRPrwg2IHxffoFOL5PUfsFRt5LA3W9h8UC_bN39yt4N5N11_c", + "d": "ejTRL_rY5gM7zGKczfW6dLGjrxWldwJ76ofVSQ2lUgF_3nZCli73XV4x50mY4jyuY-kUwKCjVR0nRfYj6sbaRY1FCxKVhkiWaS-FSTjlpghRKd2lxHwu_Ne6gOxdzp8lvlhcFs9nim9QSulhd6Y6jNPd8HRjbDGNsAF_DS1icTeKYhisHG04HSjn1roEO7FHgJBh5kGlxJyfS7jSYTZGkab9QjRCgfjERSX7-dClXmMCcZZFn4tX8EIrT0uD7E29X4dWlCD8wm-fcKT-rGJaMQyK81p3ewv-Fnon-GlqJ74jabQfgXlHXVFFOr75JwUt93nOH4N6Ss_l9jBqMEQ1VQ", + "e": "AQAB", + "use": "sig", + "kid": "5t-FNLBU_yFN-Vk8TUTVsZII2g40uD5grosbn4-NFPE", + "qi": "BDiiruwR4YHkHyvXQ3rNtTIf0uM9pr-zI1EDPuKh478NLsiY0MJY_0ntrBCXeto6x-dRcgZduCAiG7PHLLeN4NIpjTl1hZUg3L8z29MA3rTtvRu1DR5aNUwtjewg-HC-B9AfLa35Pnq7a4d047gcRlb_QQt83YgqDTdqkK0z0wA", + "dp": "nDVhUujW3SwYJUCFuRyY91U3reuldgd47PEMVgf4i0XDtOxdMvhc6RLPhUedkn3cXFOO96iQIAjk3ToAAbFs-gNuRXneU8FC39_HEriaJ-w-w9UMr-MNm-nl9L__SDnUQlX8zFGEI2lsNTQiK8gtmp60DHPaeKoE_jEgoWXav20", + "alg": "RS256", + "dq": "XBedfLyehUHvACJAkiOlSt-o4Japj45-3IdK90gpXwilImIp9gLgfImqjJ-wBFz92xlECEIzMPBCaNAruoUbR9unUC0axr0XZvyZ1eP5xVxItTE1tGkNxe6IF5EiRO8aZb-n8lklV-paiCYyrTbuy9N5MT8opSK5Ym4tU-_-IOk", + "n": "yPRP6Si1wj9VFjiFCvE5AmWAz47GHHRvNNe-fPo3C5lrWiOHCiBr_YshTSjio1qrLzA_4lVW6XAo0hoT57ze3Whc6Jj7FDOy9SnZyUZY07_xHKwdsHQnwP3_h2Ofl8Th51XTBNIVCqlmR4cQmRPoM7xeJEfje_6zpMIoMDqT8eoGfG5CI6YKlD2CuvN3apbvRcpUqqtu9zozy05mByH8bC-UCWSw46jvJvbuUc904csBTq3BU7-UyPZBSE6Pje6qfKX97nV58t6uI7neHW-vBgXcm72kfQ9W5hY-FByWSF3NbsTEvR_MdgNpTQaKZug_urIeQU5FkVlcwP3J0oAyZQ" + } +} \ No newline at end of file diff --git a/src/test/resources/validConfigBlankScope.json b/src/test/resources/validConfigBlankScope.json new file mode 100644 index 0000000..cede2cd --- /dev/null +++ b/src/test/resources/validConfigBlankScope.json @@ -0,0 +1,26 @@ +{ + "name": "testName", + "clientId": "testClientId", + "clientAuthType": "testAuthType", + "owners": [ + "testOwners" + ], + "scopes": [ + "factset.api.a", + "" + ], + "jwk": { + "p": "-y0YLv7K6cvmu9sJSyXJbjHQgqpJMkqWF8El5MSCnUpFvFd-qHDBXNGJfbol-bcomfVxiuUAxSDnyARNzoo_nDXeEDSu_4hZ82EcHeU5Mqb9hpmiy9QeMUPO__l-PIfMOH4d1ih6O84ARIpHLOx5kSfADesDyM5NgjFcbyN9CYM", + "kty": "RSA", + "q": "zNBNdbcN4aIBMw7YHGRr2Ype3lLckyFPozPgb11gs3bYTryE9A2to3Ik9Wu6rNF9V6pemf6j1jEGpX_z3Gx4EW9deY69-XdHch4ZqrSgEW1o2iykwwD1C4ocwZDRPrwg2IHxffoFOL5PUfsFRt5LA3W9h8UC_bN39yt4N5N11_c", + "d": "ejTRL_rY5gM7zGKczfW6dLGjrxWldwJ76ofVSQ2lUgF_3nZCli73XV4x50mY4jyuY-kUwKCjVR0nRfYj6sbaRY1FCxKVhkiWaS-FSTjlpghRKd2lxHwu_Ne6gOxdzp8lvlhcFs9nim9QSulhd6Y6jNPd8HRjbDGNsAF_DS1icTeKYhisHG04HSjn1roEO7FHgJBh5kGlxJyfS7jSYTZGkab9QjRCgfjERSX7-dClXmMCcZZFn4tX8EIrT0uD7E29X4dWlCD8wm-fcKT-rGJaMQyK81p3ewv-Fnon-GlqJ74jabQfgXlHXVFFOr75JwUt93nOH4N6Ss_l9jBqMEQ1VQ", + "e": "AQAB", + "use": "sig", + "kid": "5t-FNLBU_yFN-Vk8TUTVsZII2g40uD5grosbn4-NFPE", + "qi": "BDiiruwR4YHkHyvXQ3rNtTIf0uM9pr-zI1EDPuKh478NLsiY0MJY_0ntrBCXeto6x-dRcgZduCAiG7PHLLeN4NIpjTl1hZUg3L8z29MA3rTtvRu1DR5aNUwtjewg-HC-B9AfLa35Pnq7a4d047gcRlb_QQt83YgqDTdqkK0z0wA", + "dp": "nDVhUujW3SwYJUCFuRyY91U3reuldgd47PEMVgf4i0XDtOxdMvhc6RLPhUedkn3cXFOO96iQIAjk3ToAAbFs-gNuRXneU8FC39_HEriaJ-w-w9UMr-MNm-nl9L__SDnUQlX8zFGEI2lsNTQiK8gtmp60DHPaeKoE_jEgoWXav20", + "alg": "RS256", + "dq": "XBedfLyehUHvACJAkiOlSt-o4Japj45-3IdK90gpXwilImIp9gLgfImqjJ-wBFz92xlECEIzMPBCaNAruoUbR9unUC0axr0XZvyZ1eP5xVxItTE1tGkNxe6IF5EiRO8aZb-n8lklV-paiCYyrTbuy9N5MT8opSK5Ym4tU-_-IOk", + "n": "yPRP6Si1wj9VFjiFCvE5AmWAz47GHHRvNNe-fPo3C5lrWiOHCiBr_YshTSjio1qrLzA_4lVW6XAo0hoT57ze3Whc6Jj7FDOy9SnZyUZY07_xHKwdsHQnwP3_h2Ofl8Th51XTBNIVCqlmR4cQmRPoM7xeJEfje_6zpMIoMDqT8eoGfG5CI6YKlD2CuvN3apbvRcpUqqtu9zozy05mByH8bC-UCWSw46jvJvbuUc904csBTq3BU7-UyPZBSE6Pje6qfKX97nV58t6uI7neHW-vBgXcm72kfQ9W5hY-FByWSF3NbsTEvR_MdgNpTQaKZug_urIeQU5FkVlcwP3J0oAyZQ" + } +} \ No newline at end of file diff --git a/src/test/resources/validConfigWithScopes.json b/src/test/resources/validConfigWithScopes.json new file mode 100644 index 0000000..ef5c083 --- /dev/null +++ b/src/test/resources/validConfigWithScopes.json @@ -0,0 +1,26 @@ +{ + "name": "testName", + "clientId": "testClientId", + "clientAuthType": "testAuthType", + "owners": [ + "testOwners" + ], + "scopes": [ + "factset.api.a", + "factset.api.b" + ], + "jwk": { + "p": "-y0YLv7K6cvmu9sJSyXJbjHQgqpJMkqWF8El5MSCnUpFvFd-qHDBXNGJfbol-bcomfVxiuUAxSDnyARNzoo_nDXeEDSu_4hZ82EcHeU5Mqb9hpmiy9QeMUPO__l-PIfMOH4d1ih6O84ARIpHLOx5kSfADesDyM5NgjFcbyN9CYM", + "kty": "RSA", + "q": "zNBNdbcN4aIBMw7YHGRr2Ype3lLckyFPozPgb11gs3bYTryE9A2to3Ik9Wu6rNF9V6pemf6j1jEGpX_z3Gx4EW9deY69-XdHch4ZqrSgEW1o2iykwwD1C4ocwZDRPrwg2IHxffoFOL5PUfsFRt5LA3W9h8UC_bN39yt4N5N11_c", + "d": "ejTRL_rY5gM7zGKczfW6dLGjrxWldwJ76ofVSQ2lUgF_3nZCli73XV4x50mY4jyuY-kUwKCjVR0nRfYj6sbaRY1FCxKVhkiWaS-FSTjlpghRKd2lxHwu_Ne6gOxdzp8lvlhcFs9nim9QSulhd6Y6jNPd8HRjbDGNsAF_DS1icTeKYhisHG04HSjn1roEO7FHgJBh5kGlxJyfS7jSYTZGkab9QjRCgfjERSX7-dClXmMCcZZFn4tX8EIrT0uD7E29X4dWlCD8wm-fcKT-rGJaMQyK81p3ewv-Fnon-GlqJ74jabQfgXlHXVFFOr75JwUt93nOH4N6Ss_l9jBqMEQ1VQ", + "e": "AQAB", + "use": "sig", + "kid": "5t-FNLBU_yFN-Vk8TUTVsZII2g40uD5grosbn4-NFPE", + "qi": "BDiiruwR4YHkHyvXQ3rNtTIf0uM9pr-zI1EDPuKh478NLsiY0MJY_0ntrBCXeto6x-dRcgZduCAiG7PHLLeN4NIpjTl1hZUg3L8z29MA3rTtvRu1DR5aNUwtjewg-HC-B9AfLa35Pnq7a4d047gcRlb_QQt83YgqDTdqkK0z0wA", + "dp": "nDVhUujW3SwYJUCFuRyY91U3reuldgd47PEMVgf4i0XDtOxdMvhc6RLPhUedkn3cXFOO96iQIAjk3ToAAbFs-gNuRXneU8FC39_HEriaJ-w-w9UMr-MNm-nl9L__SDnUQlX8zFGEI2lsNTQiK8gtmp60DHPaeKoE_jEgoWXav20", + "alg": "RS256", + "dq": "XBedfLyehUHvACJAkiOlSt-o4Japj45-3IdK90gpXwilImIp9gLgfImqjJ-wBFz92xlECEIzMPBCaNAruoUbR9unUC0axr0XZvyZ1eP5xVxItTE1tGkNxe6IF5EiRO8aZb-n8lklV-paiCYyrTbuy9N5MT8opSK5Ym4tU-_-IOk", + "n": "yPRP6Si1wj9VFjiFCvE5AmWAz47GHHRvNNe-fPo3C5lrWiOHCiBr_YshTSjio1qrLzA_4lVW6XAo0hoT57ze3Whc6Jj7FDOy9SnZyUZY07_xHKwdsHQnwP3_h2Ofl8Th51XTBNIVCqlmR4cQmRPoM7xeJEfje_6zpMIoMDqT8eoGfG5CI6YKlD2CuvN3apbvRcpUqqtu9zozy05mByH8bC-UCWSw46jvJvbuUc904csBTq3BU7-UyPZBSE6Pje6qfKX97nV58t6uI7neHW-vBgXcm72kfQ9W5hY-FByWSF3NbsTEvR_MdgNpTQaKZug_urIeQU5FkVlcwP3J0oAyZQ" + } +} \ No newline at end of file