Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}

Expand Down Expand Up @@ -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<String> optionScopes = options.getScopes();
final List<String> 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;
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,6 +22,7 @@ public class Configuration {
private final String clientAuthType;
private final URL wellKnownUrl;
private final RSAKey jwk;
private final List<String> scopes;

/**
* Creates a valid Configuration instance containing data needed to create a JWT.
Expand All @@ -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.
Expand All @@ -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.<String>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<String> 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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<String> getScopes() {
return this.scopes;
}

private static List<String> 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<String> parsedScopes = new ArrayList<String>(scopesJson.length());
for (int i = 0; i < scopesJson.length(); i++) {
parsedScopes.add(scopesJson.getString(i));
}

return Collections.unmodifiableList(parsedScopes);
}

private static List<String> copyScopes(final List<String> scopes) {
if (scopes == null || scopes.isEmpty()) {
return Collections.emptyList();
}

return Collections.unmodifiableList(new ArrayList<String>(scopes));
}

private void checkConfig() throws ConfigurationException {
if (this.clientId == null || this.clientId.isEmpty()) {
throw new IllegalArgumentException("clientId can not be null or empty");
Expand All @@ -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");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String> scopes = Collections.emptyList();
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class TokenRequestBuilder {

private URI uri;
private SignedJWT signedJwt;
private Scope scope = new Scope();

/**
* Initialises the TokenRequestBuilder instance.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -56,7 +69,7 @@ public TokenRequest build() {
this.uri,
new PrivateKeyJWT(this.signedJwt),
new ClientCredentialsGrant(),
new Scope()
this.scope
);
}
}
Loading
Loading