From 211780fa14a11e2ad7ff57eee70ec0926a00dd85 Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Wed, 28 Jan 2026 12:08:27 -0800 Subject: [PATCH 1/7] feat: use uuid for redis key and rework some client with discovery document api --- cadc-web-token/build.gradle | 89 +++++- .../main/java/org/opencadc/token/Client.java | 296 ++++++++---------- .../org/opencadc/token/CookieEncrypt.java | 16 +- .../org/opencadc/token/DiscoveryDocument.java | 91 ++++++ .../org/opencadc/token/RedisTokenStore.java | 58 ++-- .../java/org/opencadc/token/ClientTest.java | 74 +++-- cadc-web-util/build.gradle | 1 - config/checkstyle/checkstyle.xml | 11 + gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew.bat | 178 +++++------ opencadc.gradle | 69 +++- 11 files changed, 555 insertions(+), 330 deletions(-) create mode 100644 cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java create mode 100644 config/checkstyle/checkstyle.xml diff --git a/cadc-web-token/build.gradle b/cadc-web-token/build.gradle index 742375e..c1c0526 100644 --- a/cadc-web-token/build.gradle +++ b/cadc-web-token/build.gradle @@ -11,8 +11,11 @@ plugins { id 'java-library' id 'maven-publish' - // Needed to support the old install command. Remove with Gradle version >= 7 - id 'maven' + // Automated code checking and documentation generation. + id 'jacoco' + id 'com.diffplug.spotless' version '6.25.0' + id 'checkstyle' + id 'org.jetbrains.dokka' version '1.6.0' } repositories { @@ -22,8 +25,11 @@ repositories { } group = 'org.opencadc' -version = '1.1.1' -sourceCompatibility = '1.8' +version = '1.2.0' +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} description = 'OpenCADC Cookie and OIDC Access Token manager' def git_url = 'https://github.com/opencadc/web.git' @@ -42,14 +48,81 @@ publishing { } dependencies { - api 'com.nimbusds:oauth2-oidc-sdk:11.12' + api 'com.nimbusds:oauth2-oidc-sdk:11.31.1' implementation 'org.opencadc:cadc-registry:[1.7.4,2.0.0)' - implementation 'org.opencadc:cadc-util:[1.10.0,2.0.0)' - implementation 'org.apache.commons:commons-jcs3:[3.2,3.3)' + implementation 'org.opencadc:cadc-util:[1.12.14,2.0.0)' implementation 'org.apache.commons:commons-lang3:[3.11,4.0)' - implementation 'redis.clients:jedis:[5.0.2,6.0.0)' + implementation 'redis.clients:jedis:[7.2.1,8.0.0)' // Use JUnit test framework. testImplementation 'junit:junit:[4.13.2, 5.0)' } + +spotless { + java { + // Interpret all files as utf-8 + encoding 'UTF-8' + // Use the default importOrder configuration + importOrder() + // Remove unused imports + removeUnusedImports() + // Google Java Format, Android Open Source Project style which uses 4 spaces for indentation + palantirJavaFormat('2.50.0').formatJavadoc(true) + // Format annotations on a single line + formatAnnotations() + } +} +check.dependsOn spotlessCheck + +// Example in a Gradle build script +afterEvaluate { + def spotless = getProject().getExtensions().findByName("com.diffplug.spotless") + if (spotless != null) { + File gitFolder = file('.git') // Relative or absolute path + if (gitFolder.exists()) { + spotless.ratchetFrom('origin/main') + } else { + spotless.ratchetFrom(null) // No ratchet if .git folder does not exist + } + } +} + +// Create Java Code Coverage Reports +jacocoTestReport { + reports { + xml.required = true + html.required = true + } +} +check.dependsOn jacocoTestReport + +// Create JavaDoc +javadoc { + destinationDir = file("${layout.buildDirectory}/docs/javadoc") +} + +// Create Java Documentation using Dokka for Github Markdown and HTML +tasks.dokkaGfm.configure { + outputDirectory.set(file("${layout.buildDirectory}/docs/dokka/gfm")) + dokkaSourceSets { + register("main") { + sourceRoots.from(file("src/main/java")) + } + } +} +tasks.dokkaHtml.configure { + outputDirectory.set(file("${layout.buildDirectory}/docs/dokka/html")) + dokkaSourceSets { + register("main") { + sourceRoots.from(file("src/main/java")) + } + configureEach { + jdkVersion.set(11) + sourceLink { + localDirectory.set(file("src/main/java")) + remoteUrl.set("https://github.com/opencadc/web/tree/main/cadc-web-token/src/main/java") + } + } + } +} \ No newline at end of file diff --git a/cadc-web-token/src/main/java/org/opencadc/token/Client.java b/cadc-web-token/src/main/java/org/opencadc/token/Client.java index d3a8695..213cf0e 100644 --- a/cadc-web-token/src/main/java/org/opencadc/token/Client.java +++ b/cadc-web-token/src/main/java/org/opencadc/token/Client.java @@ -69,11 +69,9 @@ package org.opencadc.token; import ca.nrc.cadc.auth.NotAuthenticatedException; -import ca.nrc.cadc.net.HttpGet; import ca.nrc.cadc.reg.Standards; import ca.nrc.cadc.reg.client.LocalAuthority; import ca.nrc.cadc.util.StringUtil; -import com.nimbusds.oauth2.sdk.AccessTokenResponse; import com.nimbusds.oauth2.sdk.AuthorizationCode; import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; import com.nimbusds.oauth2.sdk.AuthorizationErrorResponse; @@ -95,33 +93,20 @@ import com.nimbusds.oauth2.sdk.id.ClientID; import com.nimbusds.oauth2.sdk.id.State; import com.nimbusds.oauth2.sdk.token.RefreshToken; -import org.apache.commons.lang3.RandomStringUtils; -import org.apache.log4j.Logger; -import org.json.JSONObject; - -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; -import java.io.Reader; -import java.io.StringWriter; -import java.io.Writer; -import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.util.Arrays; import java.util.Objects; +import org.apache.log4j.Logger; +import org.json.JSONObject; - -/** - * A configured Client necessary to connect to an OpenID Connect Provider from a CADC/CANFAR application. - */ +/** A configured Client necessary to connect to an OpenID Connect Provider from a CADC/CANFAR application. */ public class Client { private static final Logger LOGGER = Logger.getLogger(Client.class); - private static final String WELL_KNOWN_ENDPOINT = "/.well-known/openid-configuration"; - private static final String AUTH_ENDPOINT_KEY = "authorization_endpoint"; - private static final String TOKEN_ENDPOINT_KEY = "token_endpoint"; - + // Cache the Discovery Document for efficiency. + private final DiscoveryDocument discoveryDocument; private final String clientID; private final String clientSecret; @@ -130,42 +115,81 @@ public class Client { private final String[] scope; private final TokenStore tokenStore; - /** - * Full constructor. Mostly used for testing, but feel free to use an alternate TokenStore implementation. + * Full constructor. Mostly used for testing, but feel free to use an alternate TokenStore implementation. * - * @param clientID The ID (Not the name) of the configured Client registered at the provider. + * @param clientID The ID (Not the name) of the configured Client registered at the provider. * @param clientSecret The secret associated with the Client ID for authorization to the provider. - * @param callbackURL Where to send the user after successful login and successful redirect to callback. - * @param redirectURL The Callback URL to redirect the user to after successful login. - * @param scope The array of Scope values to send. - * @param tokenStore The TokenStore cache. + * @param callbackURL Where to send the user after successful login and successful redirect to callback. + * @param redirectURL The Callback URL to redirect the user to after successful login. + * @param scope The array of Scope values to send. + * @param tokenStore The TokenStore cache. */ - public Client(String clientID, String clientSecret, URL callbackURL, URL redirectURL, String[] scope, - TokenStore tokenStore) { - this.clientID = clientID; - this.clientSecret = clientSecret; - this.callbackURL = callbackURL; - this.redirectURL = redirectURL; - this.scope = scope; - this.tokenStore = tokenStore; + public Client( + String clientID, + String clientSecret, + URL callbackURL, + URL redirectURL, + String[] scope, + TokenStore tokenStore) { + this( + DiscoveryDocument.fromIssuer(Client.getIssuer()), + clientID, + clientSecret, + callbackURL, + redirectURL, + scope, + tokenStore); } /** - * Full (mostly) constructor. + * Public constructor meant for callers. * - * @param clientID The ID (Not the name) of the configured Client registered at the provider. - * @param clientSecret The secret associated with the Client ID for authorization to the provider. - * @param callbackURL Where to send the user after successful login and successful redirect to callback. - * @param redirectURL The Callback URL to redirect the user to after successful login. - * @param scope The array of Scope values to send. + * @param clientID The ID (Not the name) of the configured Client registered at the provider. + * @param clientSecret The secret associated with the Client ID for authorization to the provider. + * @param callbackURL Where to send the user after successful login and successful redirect to callback. + * @param redirectURL The Callback URL to redirect the user to after successful login. + * @param scope The array of Scope values to send. * @param tokenStoreCacheURL The URL to the default cache implementation. */ - public Client(String clientID, String clientSecret, URL callbackURL, URL redirectURL, String[] scope, - String tokenStoreCacheURL) { + public Client( + String clientID, + String clientSecret, + URL callbackURL, + URL redirectURL, + String[] scope, + String tokenStoreCacheURL) { this(clientID, clientSecret, callbackURL, redirectURL, scope, new RedisTokenStore(tokenStoreCacheURL)); } + /** + * Full constructor. Mostly used for testing. + * + * @param discoveryDocument The OIDC Discovery Document. + * @param clientID The ID (Not the name) of the configured Client registered at the provider. + * @param clientSecret The secret associated with the Client ID for authorization to the provider. + * @param callbackURL Where to send the user after successful login and successful redirect to callback. + * @param redirectURL The Callback URL to redirect the user to after successful login. + * @param scope The array of Scope values to send. + * @param tokenStore The Token Store cache implementation. + */ + Client( + DiscoveryDocument discoveryDocument, + String clientID, + String clientSecret, + URL callbackURL, + URL redirectURL, + String[] scope, + TokenStore tokenStore) { + this.discoveryDocument = discoveryDocument; + this.clientID = clientID; + this.clientSecret = clientSecret; + this.callbackURL = callbackURL; + this.redirectURL = redirectURL; + this.scope = scope; + this.tokenStore = tokenStore; + } + /** * Obtain the URL that the user will be redirected to after successful login and redirect_uri. * @@ -187,7 +211,7 @@ public URL getRedirectURL() { /** * Obtain the login endpoint without the optional State string. * - * @return URI to redirect the user to. Never null. + * @return URI to redirect the user to. Never null. * @throws IOException If any URLs cannot be used. */ public URL getAuthorizationURL() throws IOException { @@ -198,12 +222,13 @@ public URL getAuthorizationURL() throws IOException { * Obtain the login endpoint, but provide the optional State string to be stored by the caller. * * @param stateString The state value to check later (optional). - * @return URI to redirect the user to. Never null. + * @return URI to redirect the user to. Never null. * @throws IOException If any URLs cannot be used. */ public URL getAuthorizationURL(final String stateString) throws IOException { // The authorization endpoint of the server - final URI authorizationEndpoint = URI.create(Client.getAuthorizationEndpoint().toExternalForm()); + final URI authorizationEndpoint = + URI.create(this.discoveryDocument.getAuthorizationEndpoint().toExternalForm()); // The client identifier provisioned by the server final ClientID clientID = new ClientID(this.clientID); @@ -214,11 +239,11 @@ public URL getAuthorizationURL(final String stateString) throws IOException { // The client callback URI, typically pre-registered with the server final URI callback = URI.create(this.redirectURL.toExternalForm()); - final AuthorizationRequest.Builder requestBuilder = - new AuthorizationRequest.Builder(new ResponseType(ResponseType.Value.CODE), clientID) - .scope(scope) - .redirectionURI(callback) - .endpointURI(authorizationEndpoint); + final AuthorizationRequest.Builder requestBuilder = new AuthorizationRequest.Builder( + new ResponseType(ResponseType.Value.CODE), clientID) + .scope(scope) + .redirectionURI(callback) + .endpointURI(authorizationEndpoint); if (StringUtil.hasText(stateString)) { requestBuilder.state(new State(stateString)); @@ -252,11 +277,11 @@ public String getAccessToken(final String encryptedCookieValue) throws Exception } /** - * Obtain an access token from the token endpoint for the current configuration, obtaining necessary elements - * from the provided response URI from the authorization endpoint. This will not use the optional State. + * Obtain an access token from the token endpoint for the current configuration, obtaining necessary elements from + * the provided response URI from the authorization endpoint. This will not use the optional State. * * @param responseURI The response URI from the authorization's login. - * @return The encrypted Assets key. Never null. + * @return The encrypted Assets key. Never null. * @throws IOException If any URLs cannot be used. */ public byte[] setAccessToken(final URI responseURI) throws Exception { @@ -265,12 +290,12 @@ public byte[] setAccessToken(final URI responseURI) throws Exception { } /** - * Obtain an access token from the token endpoint for the current configuration, obtaining necessary elements - * from the provided response URI from the authorization endpoint. + * Obtain an access token from the token endpoint for the current configuration, obtaining necessary elements from + * the provided response URI from the authorization endpoint. * * @param responseURI The response URI from the authorization's login. - * @param state The optional state value to be used to compare against later. - * @return The encrypted Assets key. Never null. + * @param state The optional state value to be used to compare against later. + * @return The encrypted Assets key. Never null. * @throws IOException If any URLs cannot be used. */ public byte[] setAccessToken(final URI responseURI, final String state) throws Exception { @@ -283,27 +308,15 @@ byte[] setAccessToken(final AuthorizationCode authorizationCode) throws Exceptio final AuthorizationGrant codeGrant = new AuthorizationCodeGrant(authorizationCode, callback); // The credentials to authenticate the client at the token endpoint - final ClientID clientID = new ClientID(this.clientID); - final Secret clientSecret = new Secret(this.clientSecret); - final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); - final URI tokenEndpoint = URI.create(Client.getTokenEndpoint().toExternalForm()); - final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, codeGrant); - final TokenResponse tokenResponse; - - try { - tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send()); - } catch (ParseException parseException) { - throw new IllegalArgumentException("Invalid or missing response parameters from token endpoint: " - + parseException.getMessage(), parseException); - } - + final TokenResponse tokenResponse = + Client.getTokenResponse(codeGrant, this.clientID, this.clientSecret, this.discoveryDocument); if (!tokenResponse.indicatesSuccess()) { // We got an error response... handleTokenErrorResponse(tokenResponse.toErrorResponse()); } - final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse(); - return setAccessToken(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString())); + return setAccessToken( + new JSONObject(tokenResponse.toSuccessResponse().toJSONObject().toJSONString())); } byte[] setAccessToken(final JSONObject tokenSet) throws Exception { @@ -336,27 +349,15 @@ Assets refresh(final Assets assets) throws Exception { final RefreshTokenGrant refreshTokenGrant = new RefreshTokenGrant(refreshToken); // The credentials to authenticate the client at the token endpoint - final ClientID clientID = new ClientID(this.clientID); - final Secret clientSecret = new Secret(this.clientSecret); - final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); - final URI tokenEndpoint = URI.create(Client.getTokenEndpoint().toExternalForm()); - final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, refreshTokenGrant); - final TokenResponse tokenResponse; - - try { - tokenResponse = TokenResponse.parse(tokenRequest.toHTTPRequest().send()); - } catch (ParseException parseException) { - throw new IllegalArgumentException("Invalid or missing response parameters from token endpoint: " - + parseException.getMessage(), parseException); - } - + final TokenResponse tokenResponse = + Client.getTokenResponse(refreshTokenGrant, this.clientID, this.clientSecret, this.discoveryDocument); if (!tokenResponse.indicatesSuccess()) { + // We got an error response... handleTokenErrorResponse(tokenResponse.toErrorResponse()); } - final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse(); - - return new Assets(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString())); + return new Assets( + new JSONObject(tokenResponse.toSuccessResponse().toJSONObject().toJSONString())); } String getAssetsKey(final String encryptedCookieValue) throws Exception { @@ -379,7 +380,7 @@ AuthorizationCode getAuthorizationCode(final URI responseURI) { * Obtain an authorization code and provide the optional state. * * @param responseURI The response URI from the authorization's login. - * @param state The state value to compare against to the response. + * @param state The state value to compare against to the response. * @return AuthorizationCode instance, never null. */ AuthorizationCode getAuthorizationCode(final URI responseURI, final State state) { @@ -389,8 +390,10 @@ AuthorizationCode getAuthorizationCode(final URI responseURI, final State state) try { response = AuthorizationResponse.parse(responseURI); } catch (ParseException parseException) { - throw new IllegalArgumentException("Invalid or missing response parameters from authorization endpoint: " - + parseException.getMessage(), parseException); + throw new IllegalArgumentException( + "Invalid or missing response parameters from authorization endpoint: " + + parseException.getMessage(), + parseException); } // Check the returned state parameter, must match the original. @@ -419,8 +422,8 @@ void handleTokenErrorResponse(final TokenErrorResponse tokenErrorResponse) { if (tokenErrorObject.getHTTPStatusCode() == 401) { throw new NotAuthenticatedException("Refresh token expired. Please re-authenticate."); } else { - throw new IllegalArgumentException("Invalid response from token server: " - + tokenErrorResponse.toJSONObject()); + throw new IllegalArgumentException( + "Invalid response from token server: " + tokenErrorResponse.toJSONObject()); } } @@ -433,8 +436,10 @@ public boolean equals(Object o) { return false; } Client that = (Client) o; - return Objects.equals(clientID, that.clientID) && Objects.equals(callbackURL, that.callbackURL) - && Objects.equals(redirectURL, that.redirectURL) && Arrays.equals(scope, that.scope); + return Objects.equals(clientID, that.clientID) + && Objects.equals(callbackURL, that.callbackURL) + && Objects.equals(redirectURL, that.redirectURL) + && Arrays.equals(scope, that.scope); } @Override @@ -444,21 +449,11 @@ public int hashCode() { return result; } - /** - * Generate a new 16-character state value for the caller. The caller will need to store this and retrieve it - * later to compare. - * - * @return String random state value. - */ - public static String generateState() { - return RandomStringUtils.randomAlphanumeric(16); - } - /** * Obtain whether the given Assets is expired, or about to expire. * * @param assets The Assets to check. - * @return True if about to expire or is expired. False otherwise. + * @return True if about to expire or is expired. False otherwise. */ public static boolean needsRefresh(final Assets assets) { return assets.isAccessTokenExpired(); @@ -467,67 +462,52 @@ public static boolean needsRefresh(final Assets assets) { /** * Obtain the Issuer base URL. * - * @return URL of the Issuer. Never null. - * @throws IOException For a poorly formed URL. + * @return URL of the Issuer. Never null. * @throws UnsupportedOperationException If the configured Issuer URL is not an HTTPS URL. */ - public static URL getIssuer() throws IOException { + public static URL getIssuer() { final LocalAuthority localAuthority = new LocalAuthority(); - final URI openIDIssuerURI = localAuthority.getServiceURI(Standards.SECURITY_METHOD_OPENID.toASCIIString()); + final URI openIDIssuerURI = localAuthority.getResourceID(Standards.SECURITY_METHOD_OPENID); if (!"https".equals(openIDIssuerURI.getScheme())) { throw new UnsupportedOperationException("OpenID Provider not configured."); } else { - return openIDIssuerURI.toURL(); + try { + return openIDIssuerURI.toURL(); + } catch (IOException e) { + throw new IllegalStateException("Malformed Issuer URL from Registry: " + openIDIssuerURI, e); + } } } /** - * Pull the Authorization Endpoint URL from the Well Known JSON document. + * Obtain a TokenResponse from the token endpoint using the given AuthorizationGrant. * - * @return URL of the Authorization Endpoint for authentication. Never null. - * @throws IOException For a poorly formed URL. + * @param authorizationGrant The grant request + * @param clientIDValue The Client ID + * @param clientSecretValue The Client Secret + * @return TokenResponse instance. Never null. Callers will need to check for success. + * @throws IOException If the request couldn't be sent, or the Token Endpoint couldn't be retrieved. + * @throws IllegalArgumentException If the token response could not be parsed. */ - public static URL getAuthorizationEndpoint() throws IOException { - final JSONObject jsonObject = Client.getWellKnownJSON(); - final String authEndpointString = jsonObject.getString(Client.AUTH_ENDPOINT_KEY); - return new URL(authEndpointString); - } - - /** - * Pull the Token Endpoint URL from the Well Known JSON document. - * - * @return URL of the Token Endpoint for access and refresh tokens. Never null. - * @throws IOException For a poorly formed URL. - */ - public static URL getTokenEndpoint() throws IOException { - final JSONObject jsonObject = Client.getWellKnownJSON(); - final String tokenEndpointString = jsonObject.getString(Client.TOKEN_ENDPOINT_KEY); - return new URL(tokenEndpointString); - } - - /** - * Obtain the .well-known endpoint JSON document. - * TODO: Cache this? - * - * @return The JSON Object of the response data. - * @throws MalformedURLException If URLs cannot be created as expected. - */ - private static JSONObject getWellKnownJSON() throws IOException { - final URL oidcIssuer = Client.getIssuer(); - final URL configurationURL = new URL(oidcIssuer.toExternalForm() + Client.WELL_KNOWN_ENDPOINT); - final Writer writer = new StringWriter(); - final HttpGet httpGet = new HttpGet(configurationURL, inputStream -> { - final Reader inputReader = new BufferedReader(new InputStreamReader(inputStream)); - final char[] buffer = new char[8192]; - int charsRead; - while ((charsRead = inputReader.read(buffer)) >= 0) { - writer.write(buffer, 0, charsRead); - } - writer.flush(); - }); - - httpGet.run(); + private static TokenResponse getTokenResponse( + final AuthorizationGrant authorizationGrant, + final String clientIDValue, + final String clientSecretValue, + final DiscoveryDocument discoveryDocument) + throws IOException { + final ClientID clientID = new ClientID(clientIDValue); + final Secret clientSecret = new Secret(clientSecretValue); + final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); + final URI tokenEndpoint = + URI.create(discoveryDocument.getTokenEndpoint().toExternalForm()); + final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, authorizationGrant, null); - return new JSONObject(writer.toString()); + try { + return TokenResponse.parse(tokenRequest.toHTTPRequest().send()); + } catch (ParseException parseException) { + throw new IllegalArgumentException( + "Invalid or missing response parameters from token endpoint: " + parseException.getMessage(), + parseException); + } } } diff --git a/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java b/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java index 326ff4c..26dc3fd 100644 --- a/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java +++ b/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java @@ -68,11 +68,6 @@ package org.opencadc.token; -import org.apache.commons.lang3.RandomStringUtils; - -import javax.crypto.Cipher; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.Key; @@ -80,13 +75,16 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.apache.commons.lang3.RandomStringUtils; class CookieEncrypt { private static final String DEFAULT_CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; private final String algorithm; - public CookieEncrypt() { this(CookieEncrypt.DEFAULT_CIPHER_ALGORITHM); } @@ -96,11 +94,11 @@ public CookieEncrypt() { } /** - * Encrypt the provided string value with the desired SecretKey. To use the default SecretKey, then use a generated + * Encrypt the provided string value with the desired SecretKey. To use the default SecretKey, then use a generated * #encrypt(String) method instead. * * @param value The value to encrypt. - * @param secretKey The SecretKey to use to decipher it later. + * @param secretKey The SecretKey to use to decipher it later. * @throws GeneralSecurityException For Cipher exceptions. */ EncryptedCookie encrypt(final String value, final Key secretKey) throws GeneralSecurityException { @@ -132,7 +130,7 @@ private static byte[] initializeInitializationVector(final int blockSize) { } Key generateAESKey() throws NoSuchAlgorithmException { - final String secretKeyString = RandomStringUtils.randomAlphanumeric(16); + final String secretKeyString = RandomStringUtils.secure().nextAlphanumeric(16); // Generate a Secret Key. final MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java b/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java new file mode 100644 index 0000000..0898607 --- /dev/null +++ b/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java @@ -0,0 +1,91 @@ +package org.opencadc.token; + +import ca.nrc.cadc.net.NetUtil; +import ca.nrc.cadc.reg.client.CachingFile; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Objects; +import org.apache.log4j.Logger; +import org.json.JSONObject; + +/** + * Represents an OpenID Connect Discovery Document, which provides endpoints and other configuration information about + * an OpenID Connect Provider (OP). + */ +public class DiscoveryDocument { + private static final Logger LOGGER = Logger.getLogger(DiscoveryDocument.class.getName()); + private static final String CONFIG_CACHE_DIR = "opencadc-oidc-cache"; + private static final String AUTH_ENDPOINT_KEY = "authorization_endpoint"; + private static final String TOKEN_ENDPOINT_KEY = "token_endpoint"; + + static final String WELL_KNOWN_ENDPOINT = "/.well-known/openid-configuration"; + private final CachingFile cachingFile; + + /** + * Construct a DiscoveryDocument from the given discovery document URL. Used for testing. + * + * @param discoveryDocumentURL The URL of the discovery document. + * @throws IOException If there is an error determining the domain name of the URL. + */ + DiscoveryDocument(final URL discoveryDocumentURL) throws IOException { + Objects.requireNonNull(discoveryDocumentURL, "discoveryDocumentURL from issuer cannot be null"); + final String domainName = NetUtil.getDomainName(discoveryDocumentURL); + this.cachingFile = new CachingFile( + DiscoveryDocument.getBaseCacheDirectory(domainName).toFile(), discoveryDocumentURL); + } + + private JSONObject getDiscoveryDocumentFileContent() throws IOException { + return new JSONObject(this.cachingFile.getContent()); + } + + /** + * Instantiate a DiscoveryDocument from the given issuer URL. + * + * @param issuerURL The issuer URL. + * @return DiscoveryDocument instance. + */ + static DiscoveryDocument fromIssuer(final URL issuerURL) { + try { + final URL discoveryDocumentURL = + new URL(issuerURL.toExternalForm() + DiscoveryDocument.WELL_KNOWN_ENDPOINT); + return new DiscoveryDocument(discoveryDocumentURL); + } catch (IOException e) { + throw new IllegalStateException("Unable to create DiscoveryDocument from issuer URL: " + issuerURL, e); + } + } + + /** + * Get the Authorization Endpoint URL from the Discovery Document. + * + * @return URL of the Authorization Endpoint. + * @throws IOException If the document cannot be read. + */ + URL getAuthorizationEndpoint() throws IOException { + final String urlString = this.getDiscoveryDocumentFileContent().getString(DiscoveryDocument.AUTH_ENDPOINT_KEY); + return new URL(urlString); + } + + /** + * Get the Token Endpoint URL from the Discovery Document. + * + * @return URL of the Token Endpoint. + * @throws IOException If the document cannot be read. + */ + URL getTokenEndpoint() throws IOException { + final String urlString = this.getDiscoveryDocumentFileContent().getString(DiscoveryDocument.TOKEN_ENDPOINT_KEY); + return new URL(urlString); + } + + private static Path getBaseCacheDirectory(final String issuerDomainName) { + final String tmpDir = System.getProperty("java.io.tmpdir"); + if (tmpDir == null) { + throw new RuntimeException("No tmp system dir defined."); + } + + final Path path = Paths.get(tmpDir, DiscoveryDocument.CONFIG_CACHE_DIR, issuerDomainName); + LOGGER.debug("Base cache dir: " + path); + return path; + } +} diff --git a/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java index df96b68..2fb3a5d 100644 --- a/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java +++ b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java @@ -68,78 +68,74 @@ package org.opencadc.token; - -import redis.clients.jedis.JedisPooled; - import java.util.HashMap; import java.util.Map; import java.util.NoSuchElementException; - +import java.util.UUID; +import redis.clients.jedis.RedisClient; /** - * Default TokenStore implementation access to a cache. By default, this relies on a Redis instance and the Jedis - * Java library. + * Default TokenStore implementation access to a cache. By default, this relies on a Redis instance and the Jedis Java + * library. */ class RedisTokenStore implements TokenStore { private static final String ACCESS_TOKEN_FIELD = "accessToken"; private static final String REFRESH_TOKEN_FIELD = "refreshToken"; private static final String EXPIRES_AT_MS_TOKEN_FIELD = "expiresAtMS"; - private static final String KEY_FIELD = "asset_key_index"; - private final JedisPooled jedisPool; - - RedisTokenStore() { - this.jedisPool = new JedisPooled(); - } + private final RedisClient redisClient; RedisTokenStore(final String url) { - this.jedisPool = new JedisPooled(url); + this.redisClient = RedisClient.builder().fromURI(url).build(); } + private static Map getPayload(final Assets assets) { + final Map payload = new HashMap<>(); + payload.put(RedisTokenStore.ACCESS_TOKEN_FIELD, assets.getAccessToken()); + payload.put(RedisTokenStore.REFRESH_TOKEN_FIELD, assets.getRefreshToken()); + payload.put(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD, Long.toString(assets.getExpiryTimeMilliseconds())); + return payload; + } /** - * Insert a new Asset, then return the generated key. + * Insert a new Asset, then return the generated key. Use UUIDs to ensure uniqueness. * * @param assets The Assets to store. * @return String key, never null. */ @Override public String put(final Assets assets) { - final String assetsKey = Long.toString(this.jedisPool.incrBy(RedisTokenStore.KEY_FIELD, 1L)); - put(assetsKey, assets); - - return assetsKey; + final String assetKey = UUID.randomUUID().toString(); + this.put(assetKey, assets); + return assetKey; } /** * Insert or update an Asset at the given key. * * @param assetsKey The key to store the Assets at. - * @param assets The Assets to store. + * @param assets The Assets to store. */ @Override public void put(final String assetsKey, final Assets assets) { - final Map assetsHash = new HashMap<>(); - assetsHash.put(RedisTokenStore.ACCESS_TOKEN_FIELD, assets.getAccessToken()); - assetsHash.put(RedisTokenStore.REFRESH_TOKEN_FIELD, assets.getRefreshToken()); - assetsHash.put(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD, Long.toString(assets.getExpiryTimeMilliseconds())); - this.jedisPool.hset(assetsKey, assetsHash); + this.redisClient.hset(assetsKey, RedisTokenStore.getPayload(assets)); } /** * Obtain the Assets from the cache at the given key, or throw an Exception. * * @param assetsKey The key to look up. - * @return The Assets document. Never null. - * @throws NoSuchElementException If the given key returns nothing. + * @return The Assets document. Never null. + * @throws NoSuchElementException If the given key returns nothing. */ @Override public Assets get(final String assetsKey) { - if (jedisPool.exists(assetsKey)) { - final Map assetsHash = jedisPool.hgetAll(assetsKey); - return new Assets(assetsHash.get(RedisTokenStore.ACCESS_TOKEN_FIELD), - assetsHash.get(RedisTokenStore.REFRESH_TOKEN_FIELD), - Long.parseLong(assetsHash.get(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD))); + if (redisClient.exists(assetsKey)) { + final Map assetsHash = redisClient.hgetAll(assetsKey); + return new Assets( + assetsHash.get(RedisTokenStore.ACCESS_TOKEN_FIELD), + assetsHash.get(RedisTokenStore.REFRESH_TOKEN_FIELD), + Long.parseLong(assetsHash.get(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD))); } else { throw new NoSuchElementException("No asset with key " + assetsKey); } diff --git a/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java b/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java index 221e19b..2ece0cb 100644 --- a/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java +++ b/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java @@ -73,14 +73,19 @@ import ca.nrc.cadc.auth.NotAuthenticatedException; import com.nimbusds.oauth2.sdk.AuthorizationCode; import com.nimbusds.oauth2.sdk.id.State; -import org.json.JSONObject; -import org.junit.Test; - +import java.io.IOException; import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; +import org.json.JSONObject; +import org.junit.Test; public class ClientTest { + static DiscoveryDocument getDiscoveryDocument() throws IOException { + final URL issuerURL = new URL("https://example.org/issuer"); + return DiscoveryDocument.fromIssuer(issuerURL); + } + @Test public void needsRefresh() { final long goodExpiryTime = System.currentTimeMillis() - 50000L; @@ -96,12 +101,14 @@ public void needsRefresh() { @Test public void setAndGet() throws Exception { - final Client testSubject = new Client("clientID", "clientSecret", - new URL("https://example.org/myapp/redirect"), - new URL("https://example.org/myapp/callback"), - new String[] { - "openid" - }, new TestTokenStore()); + final Client testSubject = new Client( + ClientTest.getDiscoveryDocument(), + "clientID", + "clientSecret", + new URL("https://example.org/myapp/redirect"), + new URL("https://example.org/myapp/callback"), + new String[] {"openid"}, + new TestTokenStore()); // Emulate the JSON coming from the OpenID Connect Provider. final JSONObject testTokenSet = new JSONObject(); @@ -118,12 +125,14 @@ public void setAndGet() throws Exception { @Test public void getAuthorizationCode() throws Exception { - final Client testSubject = new Client("clientID", "clientSecret", - new URL("https://example.org/myapp/redirect"), - new URL("https://example.org/myapp/callback"), - new String[] { - "openid" - }, new TestTokenStore()); + final Client testSubject = new Client( + ClientTest.getDiscoveryDocument(), + "clientID", + "clientSecret", + new URL("https://example.org/myapp/redirect"), + new URL("https://example.org/myapp/callback"), + new String[] {"openid"}, + new TestTokenStore()); final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode"); final AuthorizationCode authorizationCode = testSubject.getAuthorizationCode(testURI); @@ -132,21 +141,24 @@ public void getAuthorizationCode() throws Exception { @Test public void getAuthorizationCodeErrors() throws Exception { - final Client testSubject = new Client("clientID", "clientSecret", - new URL("https://example.org/myapp/redirect"), - new URL("https://example.org/myapp/callback"), - new String[] { - "openid" - }, new TestTokenStore()); + final Client testSubject = new Client( + ClientTest.getDiscoveryDocument(), + "clientID", + "clientSecret", + new URL("https://example.org/myapp/redirect"), + new URL("https://example.org/myapp/callback"), + new String[] {"openid"}, + new TestTokenStore()); try { final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode&state=mystate"); testSubject.getAuthorizationCode(testURI); fail("Should throw IllegalStateException"); } catch (IllegalStateException illegalStateException) { - assertEquals("Wrong message.", - "Response state expected, but none provided to compare to by caller.", - illegalStateException.getMessage()); + assertEquals( + "Wrong message.", + "Response state expected, but none provided to compare to by caller.", + illegalStateException.getMessage()); } try { @@ -154,9 +166,10 @@ public void getAuthorizationCodeErrors() throws Exception { testSubject.getAuthorizationCode(testURI, new State("mystate")); fail("Should throw IllegalStateException"); } catch (IllegalStateException illegalStateException) { - assertEquals("Wrong message", - "Caller state expected, but none provided to compare to by response.", - illegalStateException.getMessage()); + assertEquals( + "Wrong message", + "Caller state expected, but none provided to compare to by response.", + illegalStateException.getMessage()); } try { @@ -164,9 +177,10 @@ public void getAuthorizationCodeErrors() throws Exception { testSubject.getAuthorizationCode(testURI, new State("mystatetwo")); fail("Should throw NotAuthenticatedException"); } catch (NotAuthenticatedException notAuthenticatedException) { - assertEquals("Wrong message", - "Caller state does not match request state! Possible tampering.", - notAuthenticatedException.getMessage()); + assertEquals( + "Wrong message", + "Caller state does not match request state! Possible tampering.", + notAuthenticatedException.getMessage()); } } } diff --git a/cadc-web-util/build.gradle b/cadc-web-util/build.gradle index be13b06..0443ebd 100644 --- a/cadc-web-util/build.gradle +++ b/cadc-web-util/build.gradle @@ -2,7 +2,6 @@ plugins { id 'java' id 'maven-publish' id 'checkstyle' - id 'maven' } repositories { diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..34c02bc --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 442d913..3ae1e2f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..107acd3 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,89 +1,89 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/opencadc.gradle b/opencadc.gradle index 1f83014..6e4c9f4 100644 --- a/opencadc.gradle +++ b/opencadc.gradle @@ -1,10 +1,14 @@ configurations { checkstyleDep + intTestImplementation.extendsFrom testImplementation + + runtimeOnly.exclude module: 'slf4j-reload4j' + runtimeOnly.exclude module: 'reload4j' } dependencies { testImplementation 'com.puppycrawl.tools:checkstyle:8.2' - checkstyleDep 'org.opencadc:cadc-quality:1.+' + checkstyleDep 'org.opencadc:cadc-quality:[1.0,)' } checkstyle { @@ -14,9 +18,19 @@ checkstyle { sourceSets = [] } -// Temporary work around for issue https://github.com/gradle/gradle/issues/881 - -// gradle not displaying fail build status when warnings reported --> +sourceSets { + intTest { + java { + compileClasspath += main.output + test.output + runtimeClasspath += compileClasspath + } + resources.srcDirs += file('src/intTest/resources') + resources.srcDirs += new File(System.getenv('A') + '/test-certificates/') + } +} +// Temporary work around for issue https://github.com/gradle/gradle/issues/881 - +// gradle not displaying fail build status when warnings reported --> tasks.withType(Checkstyle).each { checkstyleTask -> checkstyleTask.doLast { reports.all { report -> @@ -27,3 +41,52 @@ tasks.withType(Checkstyle).each { checkstyleTask -> } } } + +tasks.withType(Test).configureEach { + // reset the report destinations so that intTests go to their own page + //reports.html.destination = file("${reporting.baseDir}/${name}") + reports.html.outputLocation = file(reporting.baseDir.getAbsolutePath() + '/' + name) + + // Assign all Java system properties from + // the command line to the tests + systemProperties System.properties +} + +tasks.register('intTest', Test) { + // set the configuration context + testClassesDirs = sourceSets.intTest.output.classesDirs + classpath = sourceSets.intTest.runtimeClasspath + + // run the tests always + outputs.upToDateWhen { false } +} + +test { + testLogging { + events "PASSED", "FAILED", "SKIPPED" + // "STARTED", + } +} + +intTest { + testLogging { + events "PASSED", "FAILED", "SKIPPED" + // "STARTED", + } +} + +pluginManager.withPlugin('maven-publish') { + // configure maven-publish to support publishToMavenLocal + publishing { + publications { + thisLibrary(MavenPublication) { + from components.java + } + } + } +} + +// backwards compat usage +tasks.register('install') { + dependsOn 'publishToMavenLocal' +} From 257ab956b1afbe9692a20a2fb3ca604fe3005e98 Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Wed, 28 Jan 2026 12:17:39 -0800 Subject: [PATCH 2/7] fix: fix ratchet code check --- cadc-web-token/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cadc-web-token/build.gradle b/cadc-web-token/build.gradle index c1c0526..20bbf51 100644 --- a/cadc-web-token/build.gradle +++ b/cadc-web-token/build.gradle @@ -60,6 +60,9 @@ dependencies { } spotless { + // Only format files changed since the specified git tag/commit + ratchetFrom('v1.0.0') // Adjust this tag as needed + java { // Interpret all files as utf-8 encoding 'UTF-8' From da21e03ca8478df3e152282ec0294052d8230a76 Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Wed, 28 Jan 2026 12:48:50 -0800 Subject: [PATCH 3/7] ci: fix ci --- opencadc.gradle | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/opencadc.gradle b/opencadc.gradle index 6e4c9f4..d4ff055 100644 --- a/opencadc.gradle +++ b/opencadc.gradle @@ -29,19 +29,6 @@ sourceSets { } } -// Temporary work around for issue https://github.com/gradle/gradle/issues/881 - -// gradle not displaying fail build status when warnings reported --> -tasks.withType(Checkstyle).each { checkstyleTask -> - checkstyleTask.doLast { - reports.all { report -> - def outputFile = report.destination - if (outputFile.exists() && outputFile.text.contains(" Date: Wed, 28 Jan 2026 12:53:04 -0800 Subject: [PATCH 4/7] ci: update ratchet for tag --- cadc-web-token/build.gradle | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cadc-web-token/build.gradle b/cadc-web-token/build.gradle index 20bbf51..51cd792 100644 --- a/cadc-web-token/build.gradle +++ b/cadc-web-token/build.gradle @@ -78,18 +78,18 @@ spotless { } check.dependsOn spotlessCheck -// Example in a Gradle build script -afterEvaluate { - def spotless = getProject().getExtensions().findByName("com.diffplug.spotless") - if (spotless != null) { - File gitFolder = file('.git') // Relative or absolute path - if (gitFolder.exists()) { - spotless.ratchetFrom('origin/main') - } else { - spotless.ratchetFrom(null) // No ratchet if .git folder does not exist - } - } -} +//// Example in a Gradle build script +//afterEvaluate { +// def spotless = getProject().getExtensions().findByName("com.diffplug.spotless") +// if (spotless != null) { +// File gitFolder = file('.git') // Relative or absolute path +// if (gitFolder.exists()) { +// spotless.ratchetFrom('origin/main') +// } else { +// spotless.ratchetFrom(null) // No ratchet if .git folder does not exist +// } +// } +//} // Create Java Code Coverage Reports jacocoTestReport { From 543d89a282e87e0509531ed6afdfa1c2eb527fc5 Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Wed, 28 Jan 2026 12:54:39 -0800 Subject: [PATCH 5/7] ci: update ratchet build --- .github/workflows/gradle.yml | 10 ++++++---- cadc-web-token/build.gradle | 13 ------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index c29cd28..9a60f4c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -6,12 +6,14 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v3 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: 11 + java-version: 21 - name: Build cadc-web-util with Gradle run: cd cadc-web-util && ../gradlew -i clean build test javadoc checkstyleMain - name: Build cadc-web-token with Gradle diff --git a/cadc-web-token/build.gradle b/cadc-web-token/build.gradle index 51cd792..cfe8821 100644 --- a/cadc-web-token/build.gradle +++ b/cadc-web-token/build.gradle @@ -78,19 +78,6 @@ spotless { } check.dependsOn spotlessCheck -//// Example in a Gradle build script -//afterEvaluate { -// def spotless = getProject().getExtensions().findByName("com.diffplug.spotless") -// if (spotless != null) { -// File gitFolder = file('.git') // Relative or absolute path -// if (gitFolder.exists()) { -// spotless.ratchetFrom('origin/main') -// } else { -// spotless.ratchetFrom(null) // No ratchet if .git folder does not exist -// } -// } -//} - // Create Java Code Coverage Reports jacocoTestReport { reports { From 8d3cfe662bcb9e4f59ffaeb7feb4da3c2cac4363 Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Thu, 29 Jan 2026 12:30:44 -0800 Subject: [PATCH 6/7] fix: ensure redis client is closed --- .../org/opencadc/token/RedisTokenStore.java | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java index 2fb3a5d..02aa2d9 100644 --- a/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java +++ b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java @@ -68,6 +68,7 @@ package org.opencadc.token; +import java.net.URI; import java.util.HashMap; import java.util.Map; import java.util.NoSuchElementException; @@ -83,10 +84,10 @@ class RedisTokenStore implements TokenStore { private static final String REFRESH_TOKEN_FIELD = "refreshToken"; private static final String EXPIRES_AT_MS_TOKEN_FIELD = "expiresAtMS"; - private final RedisClient redisClient; + private final URI redisURI; RedisTokenStore(final String url) { - this.redisClient = RedisClient.builder().fromURI(url).build(); + this.redisURI = URI.create(url); } private static Map getPayload(final Assets assets) { @@ -118,7 +119,9 @@ public String put(final Assets assets) { */ @Override public void put(final String assetsKey, final Assets assets) { - this.redisClient.hset(assetsKey, RedisTokenStore.getPayload(assets)); + try (final RedisClient redisClient = RedisClient.create(this.redisURI)) { + redisClient.hset(assetsKey, RedisTokenStore.getPayload(assets)); + } } /** @@ -130,14 +133,16 @@ public void put(final String assetsKey, final Assets assets) { */ @Override public Assets get(final String assetsKey) { - if (redisClient.exists(assetsKey)) { - final Map assetsHash = redisClient.hgetAll(assetsKey); - return new Assets( - assetsHash.get(RedisTokenStore.ACCESS_TOKEN_FIELD), - assetsHash.get(RedisTokenStore.REFRESH_TOKEN_FIELD), - Long.parseLong(assetsHash.get(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD))); - } else { - throw new NoSuchElementException("No asset with key " + assetsKey); + try (final RedisClient redisClient = RedisClient.create(this.redisURI)) { + if (redisClient.exists(assetsKey)) { + final Map assetsHash = redisClient.hgetAll(assetsKey); + return new Assets( + assetsHash.get(RedisTokenStore.ACCESS_TOKEN_FIELD), + assetsHash.get(RedisTokenStore.REFRESH_TOKEN_FIELD), + Long.parseLong(assetsHash.get(RedisTokenStore.EXPIRES_AT_MS_TOKEN_FIELD))); + } else { + throw new NoSuchElementException("No asset with key " + assetsKey); + } } } } From 6802c952a3d4d9769159762ba57772704bd1d08e Mon Sep 17 00:00:00 2001 From: Dustin Jenkins Date: Thu, 29 Jan 2026 12:49:12 -0800 Subject: [PATCH 7/7] test: add test --- .../org/opencadc/token/DiscoveryDocument.java | 13 +- .../opencadc/token/DiscoveryDocumentTest.java | 43 ++++ .../test/resources/discovery-document.json | 198 ++++++++++++++++++ 3 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 cadc-web-token/src/test/java/org/opencadc/token/DiscoveryDocumentTest.java create mode 100644 cadc-web-token/src/test/resources/discovery-document.json diff --git a/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java b/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java index 0898607..11997ad 100644 --- a/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java +++ b/cadc-web-token/src/main/java/org/opencadc/token/DiscoveryDocument.java @@ -6,7 +6,6 @@ import java.net.URL; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Objects; import org.apache.log4j.Logger; import org.json.JSONObject; @@ -30,10 +29,14 @@ public class DiscoveryDocument { * @throws IOException If there is an error determining the domain name of the URL. */ DiscoveryDocument(final URL discoveryDocumentURL) throws IOException { - Objects.requireNonNull(discoveryDocumentURL, "discoveryDocumentURL from issuer cannot be null"); - final String domainName = NetUtil.getDomainName(discoveryDocumentURL); - this.cachingFile = new CachingFile( - DiscoveryDocument.getBaseCacheDirectory(domainName).toFile(), discoveryDocumentURL); + this(new CachingFile( + DiscoveryDocument.getBaseCacheDirectory(NetUtil.getDomainName(discoveryDocumentURL)) + .toFile(), + discoveryDocumentURL)); + } + + DiscoveryDocument(final CachingFile cachingFile) { + this.cachingFile = cachingFile; } private JSONObject getDiscoveryDocumentFileContent() throws IOException { diff --git a/cadc-web-token/src/test/java/org/opencadc/token/DiscoveryDocumentTest.java b/cadc-web-token/src/test/java/org/opencadc/token/DiscoveryDocumentTest.java new file mode 100644 index 0000000..4967ce8 --- /dev/null +++ b/cadc-web-token/src/test/java/org/opencadc/token/DiscoveryDocumentTest.java @@ -0,0 +1,43 @@ +package org.opencadc.token; + +import ca.nrc.cadc.reg.client.CachingFile; +import ca.nrc.cadc.util.FileUtil; +import java.io.File; +import java.io.FileInputStream; +import java.net.URI; +import org.json.JSONTokener; +import org.junit.Assert; +import org.junit.Test; + +public class DiscoveryDocumentTest { + @Test + public void testDiscoveryDocument() throws Exception { + final File discoveryDocumentFile = + FileUtil.getFileFromResource("discovery-document.json", DiscoveryDocumentTest.class); + final StringBuilder jsonContent = new StringBuilder(); + try (final FileInputStream dis = new FileInputStream(discoveryDocumentFile)) { + jsonContent.append(new JSONTokener(dis).nextValue()); + } + + final CachingFile cachingFile = + new CachingFile( + discoveryDocumentFile, + URI.create("https://example.org/.well-known/openid-configuration") + .toURL()) { + @Override + public String getContent() { + return jsonContent.toString(); + } + }; + + final DiscoveryDocument discoveryDocument = new DiscoveryDocument(cachingFile); + Assert.assertEquals( + "Wrong Auth.", + "https://example.org/authorize", + discoveryDocument.getAuthorizationEndpoint().toString()); + Assert.assertEquals( + "Wrong Token.", + "https://example.org/token", + discoveryDocument.getTokenEndpoint().toString()); + } +} diff --git a/cadc-web-token/src/test/resources/discovery-document.json b/cadc-web-token/src/test/resources/discovery-document.json new file mode 100644 index 0000000..6515954 --- /dev/null +++ b/cadc-web-token/src/test/resources/discovery-document.json @@ -0,0 +1,198 @@ +{ + "request_parameter_supported": true, + "introspection_endpoint": "https://example.org/introspect", + "claims_parameter_supported": true, + "scim_endpoint": "https://example.org/scim", + "scopes_supported": [ + "openid", + "profile", + "email", + "address", + "phone", + "offline_access", + "eduperson_scoped_affiliation", + "eduperson_entitlement", + "eduperson_assurance", + "entitlements", + "aarc", + "wlcg", + "wlcg.groups", + "storage.create:/", + "voperson_external_affiliation", + "storage.write:/" + ], + "issuer": "https://example.org/", + "userinfo_encryption_enc_values_supported": [ + "XC20P", + "A256CBC+HS512", + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128CBC+HS256" + ], + "id_token_encryption_enc_values_supported": [ + "XC20P", + "A256CBC+HS512", + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128CBC+HS256" + ], + "authorization_endpoint": "https://example.org/authorize", + "request_object_encryption_enc_values_supported": [ + "XC20P", + "A256CBC+HS512", + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128CBC+HS256" + ], + "device_authorization_endpoint": "https://example.org/devicecode", + "userinfo_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512" + ], + "claims_supported": [ + "sub", + "name", + "preferred_username", + "given_name", + "family_name", + "middle_name", + "nickname", + "profile", + "picture", + "zoneinfo", + "locale", + "updated_at", + "email", + "email_verified", + "organisation_name", + "groups", + "wlcg.groups", + "external_authn" + ], + "op_policy_uri": "https://example.org/about", + "claim_types_supported": [ + "normal" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "token_endpoint": "https://example.org/token", + "response_types_supported": [ + "code", + "token" + ], + "request_uri_parameter_supported": false, + "userinfo_encryption_alg_values_supported": [ + "RSA-OAEP-512", + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5", + "RSA-OAEP-384" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "client_credentials", + "password", + "urn:ietf:params:oauth:grant-type:token-exchange", + "urn:ietf:params:oauth:grant-type:device_code" + ], + "revocation_endpoint": "https://example.org/revoke", + "userinfo_endpoint": "https://example.org/userinfo", + "op_tos_uri": "https://example.org/about", + "token_endpoint_auth_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512" + ], + "require_request_uri_registration": false, + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "id_token_encryption_alg_values_supported": [ + "RSA-OAEP-512", + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5", + "RSA-OAEP-384" + ], + "jwks_uri": "https://example.org/jwk", + "subject_types_supported": [ + "public", + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "none" + ], + "registration_endpoint": "https://example.org/iam/api/client-registration", + "request_object_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512" + ], + "request_object_encryption_alg_values_supported": [ + "RSA-OAEP-512", + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5", + "RSA-OAEP-384" + ] +}