diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..097f9f9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat text eol=crlf
+
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 622d3fe..c29cd28 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -6,12 +6,15 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up JDK 1.8
- uses: actions/setup-java@v1
- with:
- java-version: 1.8
- - name: Build cadc-web-util with Gradle
- run: cd cadc-web-util && ../gradlew -i clean build test javadoc checkstyleMain
- - name: Build cadc-web-test with Gradle
- run: cd cadc-web-test && ../gradlew -i clean build test
+ - uses: actions/checkout@v3
+ - name: Set up JDK 11
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 11
+ - 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
+ run: cd cadc-web-token && ../gradlew -i clean build test
+ - name: Build cadc-web-test with Gradle
+ run: cd cadc-web-test && ../gradlew -i clean build test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dc1bfbe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+# Ignore Gradle project-specific cache directory
+.gradle
+.idea
+
+# Ignore Gradle build output directory
+*/build
diff --git a/cadc-web-test/build.gradle b/cadc-web-test/build.gradle
index 519d245..eb58931 100644
--- a/cadc-web-test/build.gradle
+++ b/cadc-web-test/build.gradle
@@ -9,7 +9,7 @@ repositories {
mavenLocal()
}
-sourceCompatibility = 1.8
+sourceCompatibility = 11
group = 'org.opencadc'
version = '2.1.4'
@@ -17,10 +17,10 @@ description = 'OpenCADC Web UI test library'
def git_url = 'https://github.com/opencadc/web'
dependencies {
- compile 'commons-io:commons-io:[2.0,)'
- compile 'org.opencadc:cadc-util:[1.6,)'
- compile 'junit:junit:[4.13,5.0)'
- compile 'org.seleniumhq.selenium:selenium-java:[3.14,4.0)'
+ implementation 'commons-io:commons-io:[2.0,)'
+ implementation 'org.opencadc:cadc-util:[1.6,)'
+ implementation 'junit:junit:[4.13,5.0)'
+ implementation 'org.seleniumhq.selenium:selenium-java:[3.14,4.0)'
}
apply from: '../opencadc.gradle'
diff --git a/cadc-web-token/BFF.png b/cadc-web-token/BFF.png
new file mode 100644
index 0000000..67cb86a
Binary files /dev/null and b/cadc-web-token/BFF.png differ
diff --git a/cadc-web-token/README.md b/cadc-web-token/README.md
new file mode 100644
index 0000000..5b351f5
--- /dev/null
+++ b/cadc-web-token/README.md
@@ -0,0 +1,222 @@
+# OpenCADC Web Token library (0.1.0)
+
+- [Usage](#usage)
+- [OpenID Connect](#openid-connect)
+ - [Login](#login)
+- [BFF Pattern](#bff-pattern)
+ - [After Successful Login](#after-successful-login)
+ - [Authenticated Requests](#authenticated-requests)
+ - [Flow](#general-steps)
+
+## Usage
+
+The `org.opencadc.token.Client` and `org.opencadc.token.Assets` classes are the publicly available
+APIs. The `org.opencadc.token.Assets` class is never actually instantiated (although it could be), but
+rather used to deliver token values.
+
+### Login (Authorization)
+
+Your application's Login endpoint (Servlet example):
+```java
+import org.opencadc.token.Client;
+
+public class LoginServlet extends HttpServlet {
+
+ @Override
+ public void doGet(HttpServletRequest request, HttpServletResponse response) {
+ final Client webTokenClient = new Client("clientID", "clientSecret",
+ new URL("https://example.org/myapp/redirect"),
+ new URL("https://example.org/myapp/callback"),
+ new String[] {
+ "openid", "profile"
+ }, "redis://mycache:6739");
+ // Send the user to the login
+ response.sendRedirect(webTokenClient.getAuthorizationURL().toExternalForm());
+ }
+}
+```
+
+### OpenID Connect Callback (`redirect_uri`)
+
+After a successful login, users are redirected to your application's `redirect_uri` endpoint (`https://example.org/myapp/redirect` above).
+As this library uses the Authorization Code flow, a `code` query parameter will be included in the request to the callback.
+Your endpoint handling should use the client's `setAccessToken()` method, which will:
+
+1. Parse the `code` from the request URI
+2. Use the `code` to request a Token from the Token Endpoint
+3. Store the token set (access, refresh, and expiry time) into cache
+4. Return the encrypted key to the cache entry, with the necessary items to decrypt later, encoded in Base64
+5. Set that value into a cookie with `Secure` and `Http-Only` with a `Path` of `/`, with a name starting with `_Host-`.
+
+```java
+import org.opencadc.token.Client;
+
+public class OpenIDConnectRedirectCallbackServlet extends HttpServlet {
+
+ @Override
+ public void doGet(HttpServletRequest request, HttpServletResponse response) {
+ final Client webTokenClient = new Client("clientID", "clientSecret",
+ new URL("https://example.org/myapp/redirect"),
+ new URL("https://example.org/myapp/callback"),
+ new String[] {
+ "openid", "profile"
+ }, "redis://mycache:6739");
+
+ // Parse the `code` parameter, and request a token.
+ final byte[] encryptedKey = webTokenClient.setAccessToken(request.getRequestURI());
+
+ // The encryptedKey will be the cookie value.
+ final Cookie cookie = new Cookie("_Host-auth-myapp", new String(encryptedKey, StandardCharsets.ISO_8859_1));
+ cookie.setPath("/");
+ cookie.setHttpOnly(true);
+ cookie.setSecure(true);
+ response.addCookie(cookie);
+
+ response.sendRedirect(webTokenClient.getCallbackURL().toExternalForm());
+ }
+}
+```
+
+### Access Token Usage
+
+Access Tokens are obtained from the token and delivered as an encrypted value to be used in a cookie.
+
+```java
+public class ApplicationProtectedResourceServlet extends HttpServlet {
+ @Override
+ public void doGet(HttpServletRequest request, HttpServletResponse response) {
+ final Client webTokenClient = new Client("clientID", "clientSecret",
+ new URL("https://example.org/myapp/redirect"),
+ new URL("https://example.org/myapp/callback"),
+ new String[] {
+ "openid", "profile"
+ }, "redis://mycache:6739");
+ // Send the user to the login
+ final Cookie firstPartyCookie =
+ Arrays.stream(req.getCookies())
+ .filter(cookie -> "_Host-auth-myapp".equals(cookie.getName()))
+ .findFirst()
+ .orElse(null);
+
+ if (firstPartyCookie != null) {
+ // Also handles refresh automatically, if available.
+ final String accessToken = webTokenClient.getAccessToken(firstPartyCookie.getValue());
+
+ // Use the new accessToken in an GET call, or create a Subject with the token principal.
+
+ final Subject subject = new Subject();
+ subject.getPrincipals().add(new AuthorizationTokenPrincipal(AuthenticationUtil.AUTHORIZATION_HEADER,
+ AuthenticationUtil.CHALLENGE_TYPE_BEARER
+ + " " + accessToken));
+ subject.getPublicCredentials().add(
+ new AuthorizationToken(AuthenticationUtil.CHALLENGE_TYPE_BEARER, accessToken,
+ Collections.singletonList(
+ URI.create(request.getRequestURI()).getHost())));
+ subject.getPublicCredentials().add(AuthMethod.TOKEN);
+
+ // Create the Subject, then use it to make authenticated calls.
+ Subject.doAs(subject, ...);
+ }
+ }
+}
+```
+
+## OpenID Connect
+
+Browser based applications use the Authorization Code flow to authenticate users to the OpenId Provider (OIdP). A good example of how that works is shown at [Medium.com](https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660#c027), with `openid` included in the `scope` parameter.
+
+Once that flow succeeds, the OpenID Connect Client (the application) will have an Access Token (and Refresh Token) to use to make authenticated calls on behalf of the user to an API, such as Cavern or Skaha.
+
+As this flow is inefficient to use each time a request is made, the Access Token and Refresh Tokens are stored for the user, and retrieved when an authenticated call is necessary. Access Tokens cannot be securely stored in the browser however, so a secure way of doing it is to implement the [Backend For Frontend (BFF)](#bff-pattern) pattern.
+
+### Login
+
+Login is supplied by the OpenID Connect Provider, and the endpoint can be looked up using the JSON document at the `.well-known/openid-configuration` endpoint, and looking up the `authorization_endpoint` key. To start the Authorization Code flow, redirect the user to the `authorization_endpoint` with the following `properties`:
+
+| Property | Value |
+| ------- |---------------------------------------------------|
+| `scope` | `openid profile offline_access` |
+| `redirect_uri` | `https://example.com/myapplication/oidc-callback` |
+| `response_type` | `code` |
+| `client_id` | `myclient_identifier` |
+
+
+**Example**:
+
+*https[]()://example-oidc.com/authorize?client_id=asfaslkfjlkj3-asdfdsdflkj&scope=openid%20profile%20offline_access&response_type=code&redirect_uri=https%3A%2F%2Fexample.com%2Fmyapplication%2Foidc-callback*
+
+
+If successful, this will call the URL at `redirect_uri` with a `code` parameter, containing a very short lived string value:
+
+*https[]()://example.com/myapplication/oidc-callback?code=sdfue887hdyr*
+
+The `redirect_uri` endpoint can then pull the `code` query parameter, and use the `token_endpoint` from the `.well-known/openid-configuration` endpoint to exchange that `code` for tokens. In order to do that, the client must authenticate with the same `client_id` used in the Login, as well as the `client_secret`, and POST the values. The `client_secret` is typically generated by the client on registration. Code below
+is taken from the `Client` class and happens internally.
+
+**Example**:
+```java
+final String codeFromCallbackURI = request.getParameter("code");
+final ClientID clientID = new ClientID(this.clientID);
+final Secret clientSecret = new Secret(this.clientSecret);
+final AuthorizationCodeGrant codeGrant = new AuthorizationCodeGrant(codeFromCallbackURI);
+
+// Basic Authentication to obtain a Token from the IAM service.
+final ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret);
+
+final URI tokenEndpoint = URI.create(Client.getTokenEndpoint().toExternalForm());
+final TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, codeGrant);
+
+// Send the request for the token...
+final TokenResponse tokenResponse = sendTokenRequest(tokenRequest);
+final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse();
+
+// We now have the Assets (Access Token, Refresh Token, and Expiry Time of Access Token)
+final Assets assets = new Assets(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString()));
+```
+
+Document returned from the `TokenRequest`:
+```json
+{
+ "access_token": "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3",
+ "expires_in": 3600,
+ "refresh_token": "IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk",
+
+ "id_token": "asklIILLdnsf9sdjsdfhkjhjh" // Not actually used, but there if needed.
+}
+```
+
+The application now has what it needs to make authenticated calls to the API(s). Let's look at how they're stored and used with the [BFF Pattern](#bff-pattern).
+
+## BFF Pattern
+
+The UI applications use the Backend For Frontend (BFF) pattern to securely store tokens in a server-side cache, and can only be retrieved with an encrypted, HTTP-Only, and Secure, first-party cookie from the browser. First-party cookies are obtained from a direct visit to the site, such as from a redirect, rather than from a request made from the page using JavaScript (third-party). As browsers tighten security on cookies, this helps to future proof it.
+
+All OpenID Connect (OIDC) interaction is handled by the [Nimbus OAuth2 Java Library](https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/src/master/).
+
+### After Successful Login
+
+The JSON document with a token set represents the Assets. These Assets are stored in a Redis cache on the server, and a key is issued to retrieve them. Each application has its own Token Cache. The Assets are made up of the `access_token`, `refresh_token`, and the `expires_in` values.
+
+That returned Assets key is SHA-256 encrypted, and set in the browser in a secure cookie. That cookie is only good for this application, and cannot be read by JavaScript (`http-only`).
+
+### Authenticated Requests
+
+That encrypted cookie can now be used with the application to make authenticated requests. The browser will send the cookie with each request, and follow the path as laid out in the diagram.
+
+![BFF Pattern](./BFF.png)
+
+#### General Steps
+
+1. User makes a request for a resource from a browser application
+2. If there is no first-party cookie, then proceed as though anonymous. If the resource is protected, then a 401 or 403 status code is returned.
+ 1. For the Science Portal, this means denying access with a modal login box as authentication is required.
+ 2. For the Storage UI, this means producing a button to optionally authenticate, as public browsing is allowed for Public items.
+3. Decrypt the cookie if present, then use the key to look up the Assets in the Redis cache.
+4. Use the Access Token from the Assets as a Bearer token in the request header to the API.
+
+If the Access Token is valid (and the user is granted access to the resource), then the resource is returned. The system will check the `expires_in` value to determine if the Access Token will soon expire, and if so, will request a refresh.
+
+1. If no Refresh Token is present in the Assets, the user needs to re-authenticate.
+2. If a Refresh Token is present in the Assets, then request a new Access Token from the token endpoint.
+3. If the Refresh Token is expired (i.e. 401 is returned from the OIdP), then the user needs to re-authenticate.
+4. Use the refreshed Access Token and return the resource to the user.
diff --git a/cadc-web-token/build.gradle b/cadc-web-token/build.gradle
new file mode 100644
index 0000000..7c2f52b
--- /dev/null
+++ b/cadc-web-token/build.gradle
@@ -0,0 +1,52 @@
+/*
+ * This file was generated by the Gradle 'init' task.
+ *
+ * This generated file contains a sample Java library project to get you started.
+ * For more details take a look at the 'Building Java & JVM projects' chapter in the Gradle
+ * User Manual available at https://docs.gradle.org/7.6.1/userguide/building_java_projects.html
+ */
+
+plugins {
+ // Apply the java-library plugin for API and implementation separation.
+ id 'java-library'
+ id 'maven-publish'
+
+ // Needed to support the old install command. Remove with Gradle version >= 7
+ id 'maven'
+}
+
+repositories {
+ // Use Maven Central for resolving dependencies.
+ mavenCentral()
+ mavenLocal()
+}
+
+group = 'org.opencadc'
+version = '1.0.2'
+sourceCompatibility = '11'
+
+// Minimal publishing required to run publishToMavenLocal with Gradle version >= 7
+publishing {
+ publications {
+ maven(MavenPublication) {
+ groupId = group
+ version = version
+ sourceCompatibility = sourceCompatibility
+
+ from components.java
+ }
+ }
+}
+
+dependencies {
+ api 'com.nimbusds:oauth2-oidc-sdk:11.6'
+
+ 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.apache.commons:commons-lang3:[3.11,4.0)'
+ implementation 'redis.clients:jedis:[5.0.2,6.0.0)'
+
+ // Use JUnit test framework.
+ testImplementation 'junit:junit:4.13.2'
+}
diff --git a/cadc-web-token/src/main/java/org/opencadc/token/Assets.java b/cadc-web-token/src/main/java/org/opencadc/token/Assets.java
new file mode 100644
index 0000000..40082b6
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/Assets.java
@@ -0,0 +1,177 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+import ca.nrc.cadc.util.StringUtil;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+/**
+ * Class that represents the document that will be stored in cache. Instances can be updated from time to time
+ * during a refresh.
+ */
+public final class Assets {
+ // Provide a short buffer to check for the expiry time. This will be used to ensure that the expiry time isn't
+ // in the future by, say, one millisecond, which won't benefit a future request. One minute is the default.
+ private static final long EXPIRY_BUFFER_CHECK_MS = 60000L;
+
+ // Keys to access the values in JSON.
+ static final String ACCESS_TOKEN_KEY = "access_token";
+ static final String REFRESH_TOKEN_KEY = "refresh_token";
+ static final String EXPIRES_IN_KEY = "expires_in";
+ private static final String EXPIRES_AT_MS_KEY = "expires_at_ms";
+
+
+ private final String accessToken;
+ private final String refreshToken;
+ private final long expiryTimeMilliseconds;
+
+ /**
+ * Plain constructor. Used when being pulled out of cache.
+ *
+ * @param accessToken The current Access Token.
+ * @param refreshToken The current Refresh Token (if present).
+ * @param expiryTimeMilliseconds The expiry time in milliseconds. Used to compare for expiry.
+ */
+ public Assets(final String accessToken, final String refreshToken, final long expiryTimeMilliseconds) {
+ this.accessToken = accessToken;
+ this.refreshToken = refreshToken;
+ this.expiryTimeMilliseconds = expiryTimeMilliseconds;
+ }
+
+ /**
+ * A new instance based on the JSON document from the OpenID Connect Provider. This is typically used when a
+ * new Access Token is obtained through Refresh or Authorization.
+ *
+ * @param tokenSet The JSON document of tokens.
+ */
+ public Assets(final JSONObject tokenSet) {
+ this.accessToken = tokenSet.getString(Assets.ACCESS_TOKEN_KEY);
+ this.refreshToken = tokenSet.getString(Assets.REFRESH_TOKEN_KEY);
+
+ final int expirySeconds = tokenSet.getInt(Assets.EXPIRES_IN_KEY);
+ this.expiryTimeMilliseconds = System.currentTimeMillis() + (expirySeconds * 1000L);
+ }
+
+ @Override
+ public String toString() {
+ final JSONObject jsonObject = new JSONObject();
+
+ jsonObject.put(Assets.ACCESS_TOKEN_KEY, accessToken);
+
+ if (StringUtil.hasText(refreshToken)) {
+ jsonObject.put(Assets.REFRESH_TOKEN_KEY, refreshToken);
+ }
+
+ jsonObject.put(Assets.EXPIRES_AT_MS_KEY, expiryTimeMilliseconds);
+
+ return jsonObject.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Assets assets = (Assets) o;
+ return expiryTimeMilliseconds == assets.expiryTimeMilliseconds
+ && Objects.equals(accessToken, assets.accessToken)
+ && Objects.equals(refreshToken, assets.refreshToken);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(accessToken, refreshToken, expiryTimeMilliseconds);
+ }
+
+ public String getAccessToken() {
+ return this.accessToken;
+ }
+
+ public String getRefreshToken() {
+ return this.refreshToken;
+ }
+
+ public long getExpiryTimeMilliseconds() {
+ return this.expiryTimeMilliseconds;
+ }
+
+ /**
+ * Determine whether this asset's expiry time has already come, or is about to. Used to determine whether a
+ * refresh should be attempted.
+ * @return True if expiry time is in the past (or close to), false otherwise.
+ */
+ public boolean isAccessTokenExpired() {
+ return this.expiryTimeMilliseconds < (System.currentTimeMillis() - Assets.EXPIRY_BUFFER_CHECK_MS);
+ }
+}
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
new file mode 100644
index 0000000..d3a8695
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/Client.java
@@ -0,0 +1,533 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+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;
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.AuthorizationRequest;
+import com.nimbusds.oauth2.sdk.AuthorizationResponse;
+import com.nimbusds.oauth2.sdk.AuthorizationSuccessResponse;
+import com.nimbusds.oauth2.sdk.ErrorObject;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.RefreshTokenGrant;
+import com.nimbusds.oauth2.sdk.ResponseType;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.TokenErrorResponse;
+import com.nimbusds.oauth2.sdk.TokenRequest;
+import com.nimbusds.oauth2.sdk.TokenResponse;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+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;
+
+
+/**
+ * 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";
+
+
+ private final String clientID;
+ private final String clientSecret;
+ private final URL callbackURL;
+ private final URL redirectURL;
+ private final String[] scope;
+ private final TokenStore tokenStore;
+
+
+ /**
+ * 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 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.
+ */
+ 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;
+ }
+
+ /**
+ * Full (mostly) constructor.
+ *
+ * @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) {
+ this(clientID, clientSecret, callbackURL, redirectURL, scope, new RedisTokenStore(tokenStoreCacheURL));
+ }
+
+ /**
+ * Obtain the URL that the user will be redirected to after successful login and redirect_uri.
+ *
+ * @return The URL of the end callback.
+ */
+ public URL getCallbackURL() {
+ return callbackURL;
+ }
+
+ /**
+ * Obtain the URL that the user will be redirected to after successful login by the OpenID Connect provider.
+ *
+ * @return The URL of the end redirect.
+ */
+ public URL getRedirectURL() {
+ return redirectURL;
+ }
+
+ /**
+ * Obtain the login endpoint without the optional State string.
+ *
+ * @return URI to redirect the user to. Never null.
+ * @throws IOException If any URLs cannot be used.
+ */
+ public URL getAuthorizationURL() throws IOException {
+ return getAuthorizationURL("");
+ }
+
+ /**
+ * 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.
+ * @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());
+
+ // The client identifier provisioned by the server
+ final ClientID clientID = new ClientID(this.clientID);
+
+ // The requested scope values for the token
+ final Scope scope = new Scope(this.scope);
+
+ // 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);
+
+ if (StringUtil.hasText(stateString)) {
+ requestBuilder.state(new State(stateString));
+ }
+
+ final AuthorizationRequest request = requestBuilder.build();
+ return request.toURI().toURL();
+ }
+
+ /**
+ * Decrypt the given cookie value to obtain the key, then look it up in the cache to return the access token.
+ *
+ * @param encryptedCookieValue The encrypted cookie value from the caller.
+ * @return String access token.
+ * @throws Exception If the Assets with the given key don't exist, or the cookie cannot be decrypted.
+ */
+ public String getAccessToken(final String encryptedCookieValue) throws Exception {
+ final String assetsKey = getAssetsKey(encryptedCookieValue);
+ final Assets storedAssets = this.tokenStore.get(assetsKey);
+ final Assets assets;
+
+ if (Client.needsRefresh(storedAssets)) {
+ final Assets refreshedAssets = refresh(storedAssets);
+ this.tokenStore.put(assetsKey, refreshedAssets);
+ assets = refreshedAssets;
+ } else {
+ assets = storedAssets;
+ }
+
+ return assets.getAccessToken();
+ }
+
+ /**
+ * 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.
+ * @throws IOException If any URLs cannot be used.
+ */
+ public byte[] setAccessToken(final URI responseURI) throws Exception {
+ final AuthorizationCode code = getAuthorizationCode(responseURI);
+ return setAccessToken(code);
+ }
+
+ /**
+ * 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.
+ * @throws IOException If any URLs cannot be used.
+ */
+ public byte[] setAccessToken(final URI responseURI, final String state) throws Exception {
+ final AuthorizationCode code = getAuthorizationCode(responseURI, new State(state));
+ return setAccessToken(code);
+ }
+
+ byte[] setAccessToken(final AuthorizationCode authorizationCode) throws Exception {
+ final URI callback = URI.create(this.redirectURL.toExternalForm());
+ 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);
+ }
+
+ if (!tokenResponse.indicatesSuccess()) {
+ // We got an error response...
+ handleTokenErrorResponse(tokenResponse.toErrorResponse());
+ }
+
+ final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse();
+ return setAccessToken(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString()));
+ }
+
+ byte[] setAccessToken(final JSONObject tokenSet) throws Exception {
+ final Assets assets = new Assets(tokenSet);
+ return encryptAssetsKey(this.tokenStore.put(assets));
+ }
+
+ /**
+ * Encrypt the given assets key to be used in a cookie and sent to the browser.
+ *
+ * @param assetsKey The key to encrypt and put into a cookie.
+ * @return byte array of encrypted value, never null.
+ * @throws Exception If the encryption fails.
+ */
+ byte[] encryptAssetsKey(final String assetsKey) throws Exception {
+ final CookieEncrypt cookieEncrypt = new CookieEncrypt();
+ final EncryptedCookie encryptionEncryptedCookie = cookieEncrypt.encrypt(assetsKey);
+ return encryptionEncryptedCookie.marshall();
+ }
+
+ /**
+ * Perform a refresh of the given Assets and return the new version.
+ *
+ * @param assets The (possibly expired) assets to be refreshed using its refresh token.
+ * @return The refreshed Assets object.
+ * @throws Exception For any HTTP errors, or in obtaining the Token Endpoint URL.
+ */
+ Assets refresh(final Assets assets) throws Exception {
+ final RefreshToken refreshToken = new RefreshToken(assets.getRefreshToken());
+ 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);
+ }
+
+ if (!tokenResponse.indicatesSuccess()) {
+ handleTokenErrorResponse(tokenResponse.toErrorResponse());
+ }
+
+ final AccessTokenResponse tokenSuccessResponse = tokenResponse.toSuccessResponse();
+
+ return new Assets(new JSONObject(tokenSuccessResponse.toJSONObject().toJSONString()));
+ }
+
+ String getAssetsKey(final String encryptedCookieValue) throws Exception {
+ final EncryptedCookie encryptedEncryptedCookie = new EncryptedCookie(encryptedCookieValue);
+ final CookieDecrypt cookieDecrypt = new CookieDecrypt();
+ return cookieDecrypt.getAssetsKey(encryptedEncryptedCookie);
+ }
+
+ /**
+ * Obtain an authorization code without the optional state provided.
+ *
+ * @param responseURI The response URI from the authorization's login.
+ * @return AuthorizationCode instance, never null.
+ */
+ AuthorizationCode getAuthorizationCode(final URI responseURI) {
+ return getAuthorizationCode(responseURI, null);
+ }
+
+ /**
+ * 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.
+ * @return AuthorizationCode instance, never null.
+ */
+ AuthorizationCode getAuthorizationCode(final URI responseURI, final State state) {
+ // Parse the authorisation response from the callback URI
+ final AuthorizationResponse response;
+
+ try {
+ response = AuthorizationResponse.parse(responseURI);
+ } catch (ParseException parseException) {
+ throw new IllegalArgumentException("Invalid or missing response parameters from authorization endpoint: "
+ + parseException.getMessage(), parseException);
+ }
+
+ // Check the returned state parameter, must match the original.
+ final State responseState = response.getState();
+ if (responseState == null && state != null) {
+ throw new IllegalStateException("Caller state expected, but none provided to compare to by response.");
+ } else if (responseState != null && state == null) {
+ throw new IllegalStateException("Response state expected, but none provided to compare to by caller.");
+ } else if (responseState != null && !state.equals(responseState)) {
+ throw new NotAuthenticatedException("Caller state does not match request state! Possible tampering.");
+ } else if (!response.indicatesSuccess()) {
+ // The request was denied or some error occurred
+ final AuthorizationErrorResponse errorResponse = response.toErrorResponse();
+ throw new IllegalArgumentException("Invalid response from authorization server: " + errorResponse);
+ }
+
+ final AuthorizationSuccessResponse successResponse = response.toSuccessResponse();
+
+ // Retrieve the authorisation code, to be used later to exchange the code for
+ // an access token at the token endpoint of the server
+ return successResponse.getAuthorizationCode();
+ }
+
+ void handleTokenErrorResponse(final TokenErrorResponse tokenErrorResponse) {
+ final ErrorObject tokenErrorObject = tokenErrorResponse.getErrorObject();
+ if (tokenErrorObject.getHTTPStatusCode() == 401) {
+ throw new NotAuthenticatedException("Refresh token expired. Please re-authenticate.");
+ } else {
+ throw new IllegalArgumentException("Invalid response from token server: "
+ + tokenErrorResponse.toJSONObject());
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ 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);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hash(clientID, callbackURL, redirectURL);
+ result = 31 * result + Arrays.hashCode(scope);
+ 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.
+ */
+ public static boolean needsRefresh(final Assets assets) {
+ return assets.isAccessTokenExpired();
+ }
+
+ /**
+ * Obtain the Issuer base URL.
+ *
+ * @return URL of the Issuer. Never null.
+ * @throws IOException For a poorly formed URL.
+ * @throws UnsupportedOperationException If the configured Issuer URL is not an HTTPS URL.
+ */
+ public static URL getIssuer() throws IOException {
+ final LocalAuthority localAuthority = new LocalAuthority();
+ final URI openIDIssuerURI = localAuthority.getServiceURI(Standards.SECURITY_METHOD_OPENID.toASCIIString());
+ if (!"https".equals(openIDIssuerURI.getScheme())) {
+ throw new UnsupportedOperationException("OpenID Provider not configured.");
+ } else {
+ return openIDIssuerURI.toURL();
+ }
+ }
+
+ /**
+ * Pull the Authorization Endpoint URL from the Well Known JSON document.
+ *
+ * @return URL of the Authorization Endpoint for authentication. Never null.
+ * @throws IOException For a poorly formed URL.
+ */
+ 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();
+
+ return new JSONObject(writer.toString());
+ }
+}
diff --git a/cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java b/cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java
new file mode 100644
index 0000000..e31f43a
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/CookieDecrypt.java
@@ -0,0 +1,113 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.IvParameterSpec;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+/**
+ * The class to decrypt the cookie value. This will decrypt the key from the encrypted values.
+ */
+class CookieDecrypt {
+ private static final String DEFAULT_CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
+
+ private final String algorithm;
+
+
+ /**
+ * Useful for testing.
+ * @param algorithm The cipher algorithm to use.
+ */
+ CookieDecrypt(final String algorithm) {
+ this.algorithm = algorithm;
+ }
+
+ public CookieDecrypt() {
+ this(CookieDecrypt.DEFAULT_CIPHER_ALGORITHM);
+ }
+
+
+ public String getAssetsKey(final EncryptedCookie encryptedCookie)
+ throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException,
+ InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
+
+ final Cipher cipher = Cipher.getInstance(this.algorithm);
+ cipher.init(Cipher.DECRYPT_MODE, encryptedCookie.getSecretKey(),
+ new IvParameterSpec(encryptedCookie.getInitializationVector()));
+
+ return new String(cipher.doFinal(Base64.getDecoder().decode(encryptedCookie.getValue())));
+ }
+}
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
new file mode 100644
index 0000000..326ff4c
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/CookieEncrypt.java
@@ -0,0 +1,146 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+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;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+class CookieEncrypt {
+ private static final String DEFAULT_CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
+
+ private final String algorithm;
+
+
+ public CookieEncrypt() {
+ this(CookieEncrypt.DEFAULT_CIPHER_ALGORITHM);
+ }
+
+ CookieEncrypt(String algorithm) {
+ this.algorithm = algorithm;
+ }
+
+ /**
+ * 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.
+ * @throws GeneralSecurityException For Cipher exceptions.
+ */
+ EncryptedCookie encrypt(final String value, final Key secretKey) throws GeneralSecurityException {
+ final Cipher cipher = Cipher.getInstance(this.algorithm);
+ final byte[] iv = CookieEncrypt.initializeInitializationVector(cipher.getBlockSize());
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
+
+ final byte[] encryptedValue =
+ Base64.getEncoder().encode(cipher.doFinal(value.getBytes(StandardCharsets.ISO_8859_1)));
+ return new EncryptedCookie(encryptedValue, iv, secretKey);
+ }
+
+ /**
+ * Encrypt the provided string value with a generated SecretKey.
+ *
+ * @param value The value to encrypt.
+ * @throws GeneralSecurityException For Cipher exceptions.
+ */
+ public EncryptedCookie encrypt(final String value) throws GeneralSecurityException {
+ return encrypt(value, generateAESKey());
+ }
+
+ private static byte[] initializeInitializationVector(final int blockSize) {
+ final byte[] initializationVector = new byte[blockSize];
+ final SecureRandom random = new SecureRandom();
+ random.nextBytes(initializationVector);
+
+ return initializationVector;
+ }
+
+ Key generateAESKey() throws NoSuchAlgorithmException {
+ final String secretKeyString = RandomStringUtils.randomAlphanumeric(16);
+
+ // Generate a Secret Key.
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(secretKeyString.getBytes(StandardCharsets.ISO_8859_1));
+
+ final byte[] keyBytes = new byte[16];
+ System.arraycopy(digest.digest(), 0, keyBytes, 0, keyBytes.length);
+
+ return new SecretKeySpec(keyBytes, "AES");
+ }
+}
diff --git a/cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java b/cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java
new file mode 100644
index 0000000..e4081ba
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/EncryptedCookie.java
@@ -0,0 +1,146 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+import javax.crypto.spec.SecretKeySpec;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+import java.util.Base64;
+
+/**
+ * The product of the encryption. The values will all be included in the cookie value as a Base64 Encoded value.
+ */
+final class EncryptedCookie {
+ final byte[] value;
+ final byte[] initializationVector;
+ final Key secretKey;
+
+ /**
+ * Create a new instance after encrypting a key. The raw key value is in the encrypted value.
+ * @param value The encrypted key linked to the document in cache.
+ * @param initializationVector The InitializationVector value used in the encryption.
+ * @param secretKey The Secret Key value used in the encryption.
+ * @see CookieEncrypt
+ */
+ public EncryptedCookie(byte[] value, byte[] initializationVector, Key secretKey) {
+ this.value = value;
+ this.initializationVector = initializationVector;
+ this.secretKey = secretKey;
+ }
+
+
+ /**
+ * Parse out the metadata from the given String input. This is used when a cookie is received.
+ * @param input The String value from a cookie.
+ */
+ public EncryptedCookie(final String input) {
+ final byte[] decodedInput = Base64.getDecoder().decode(input.getBytes(StandardCharsets.ISO_8859_1));
+ this.initializationVector = new byte[16];
+ System.arraycopy(decodedInput, 0, this.initializationVector, 0, 16);
+
+ final byte[] secretKeyBytes = new byte[16];
+ System.arraycopy(decodedInput, 16, secretKeyBytes, 0, 16);
+ this.secretKey = new SecretKeySpec(secretKeyBytes, "AES");
+
+ final int startPos = initializationVector.length + secretKeyBytes.length;
+ this.value = new byte[decodedInput.length - startPos];
+ System.arraycopy(decodedInput, startPos, this.value, 0, value.length);
+ }
+
+ public byte[] getValue() {
+ return value;
+ }
+
+ public byte[] getInitializationVector() {
+ return initializationVector;
+ }
+
+ public Key getSecretKey() {
+ return secretKey;
+ }
+
+ /**
+ * Obtain the encoded cookie value as it should be when written out.
+ * @return A byte array of Base64 Encoded values in this instance. Never null.
+ * @throws IOException For writing data problems.
+ */
+ public byte[] marshall() throws IOException {
+ try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
+ byteArrayOutputStream.write(this.initializationVector);
+ byteArrayOutputStream.write(this.secretKey.getEncoded());
+ byteArrayOutputStream.write(this.value);
+
+ byteArrayOutputStream.flush();
+
+ return Base64.getEncoder().encode(byteArrayOutputStream.toByteArray());
+ }
+ }
+}
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
new file mode 100644
index 0000000..df96b68
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/RedisTokenStore.java
@@ -0,0 +1,147 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+
+import redis.clients.jedis.JedisPooled;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NoSuchElementException;
+
+
+/**
+ * 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();
+ }
+
+ RedisTokenStore(final String url) {
+ this.jedisPool = new JedisPooled(url);
+ }
+
+
+ /**
+ * Insert a new Asset, then return the generated key.
+ *
+ * @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;
+ }
+
+ /**
+ * Insert or update an Asset at the given key.
+ *
+ * @param assetsKey The key to store the Assets at.
+ * @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);
+ }
+
+ /**
+ * 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.
+ */
+ @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)));
+ } else {
+ throw new NoSuchElementException("No asset with key " + assetsKey);
+ }
+ }
+}
diff --git a/cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java b/cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java
new file mode 100644
index 0000000..68ef5d9
--- /dev/null
+++ b/cadc-web-token/src/main/java/org/opencadc/token/TokenStore.java
@@ -0,0 +1,98 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+import java.util.NoSuchElementException;
+
+public interface TokenStore {
+ /**
+ * Insert a new Asset, then return the generated key.
+ *
+ * @param assets The Assets to store.
+ * @return String key, never null.
+ */
+ String put(final Assets assets);
+
+ /**
+ * Insert or update an Asset at the given key.
+ *
+ * @param assetsKey The key to store the Assets at.
+ * @param assets The Assets to store.
+ */
+ void put(final String assetsKey, final Assets 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.
+ */
+ Assets get(final String 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
new file mode 100644
index 0000000..221e19b
--- /dev/null
+++ b/cadc-web-token/src/test/java/org/opencadc/token/ClientTest.java
@@ -0,0 +1,172 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+import static org.junit.Assert.*;
+
+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.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+public class ClientTest {
+ @Test
+ public void needsRefresh() {
+ final long goodExpiryTime = System.currentTimeMillis() - 50000L;
+ final Assets goodAssets = new Assets("access", "refresh", goodExpiryTime);
+
+ assertFalse("Should not need refresh.", Client.needsRefresh(goodAssets));
+
+ final long expiredExpiryTime = System.currentTimeMillis() - 61000L;
+ final Assets expiredAssets = new Assets("access", "refresh", expiredExpiryTime);
+
+ assertTrue("Should not need refresh.", Client.needsRefresh(expiredAssets));
+ }
+
+ @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());
+
+ // Emulate the JSON coming from the OpenID Connect Provider.
+ final JSONObject testTokenSet = new JSONObject();
+ testTokenSet.put(Assets.ACCESS_TOKEN_KEY, "myaccesstoken");
+ testTokenSet.put(Assets.REFRESH_TOKEN_KEY, "myrefreshtoken");
+ testTokenSet.put(Assets.EXPIRES_IN_KEY, Integer.toString(3600));
+
+ final byte[] cookieValue = testSubject.setAccessToken(testTokenSet);
+
+ final String accessToken = testSubject.getAccessToken(new String(cookieValue, StandardCharsets.ISO_8859_1));
+
+ assertEquals("Wrong accessToken", "myaccesstoken", accessToken);
+ }
+
+ @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 URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode");
+ final AuthorizationCode authorizationCode = testSubject.getAuthorizationCode(testURI);
+ assertEquals("Wrong code", "mycode", authorizationCode.getValue());
+ }
+
+ @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());
+
+ 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());
+ }
+
+ try {
+ final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode");
+ 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());
+ }
+
+ try {
+ final URI testURI = URI.create("https://example.com/myapp/redirect?code=mycode&state=mystateone");
+ 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());
+ }
+ }
+}
diff --git a/cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java b/cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java
new file mode 100644
index 0000000..fc52b13
--- /dev/null
+++ b/cadc-web-token/src/test/java/org/opencadc/token/CookieEncryptionTest.java
@@ -0,0 +1,28 @@
+/*
+ * This Java source file was generated by the Gradle 'init' task.
+ */
+package org.opencadc.token;
+
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.*;
+
+
+public class CookieEncryptionTest {
+
+ @Test
+ public void roundTrip() throws Exception {
+ final String assetKey = "ivegotabadfeelingaboutthis";
+ final CookieEncrypt cookieEncrypt = new CookieEncrypt();
+ final EncryptedCookie encryptedCookie = cookieEncrypt.encrypt(assetKey);
+
+ final String marshalledCookieValue = new String(encryptedCookie.marshall(), StandardCharsets.UTF_8);
+ final EncryptedCookie unmarshalledCookieValue = new EncryptedCookie(marshalledCookieValue);
+ final CookieDecrypt cookieDecrypt = new CookieDecrypt();
+
+ final String decrypted = cookieDecrypt.getAssetsKey(unmarshalledCookieValue);
+ assertEquals("Wrong decrypted value.", assetKey, decrypted);
+ }
+}
diff --git a/cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java b/cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java
new file mode 100644
index 0000000..e7ad769
--- /dev/null
+++ b/cadc-web-token/src/test/java/org/opencadc/token/TestTokenStore.java
@@ -0,0 +1,99 @@
+/*
+ ************************************************************************
+ ******************* CANADIAN ASTRONOMY DATA CENTRE *******************
+ ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES **************
+ *
+ * (c) 2023. (c) 2023.
+ * Government of Canada Gouvernement du Canada
+ * National Research Council Conseil national de recherches
+ * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6
+ * All rights reserved Tous droits réservés
+ *
+ * NRC disclaims any warranties, Le CNRC dénie toute garantie
+ * expressed, implied, or énoncée, implicite ou légale,
+ * statutory, of any kind with de quelque nature que ce
+ * respect to the software, soit, concernant le logiciel,
+ * including without limitation y compris sans restriction
+ * any warranty of merchantability toute garantie de valeur
+ * or fitness for a particular marchande ou de pertinence
+ * purpose. NRC shall not be pour un usage particulier.
+ * liable in any event for any Le CNRC ne pourra en aucun cas
+ * damages, whether direct or être tenu responsable de tout
+ * indirect, special or general, dommage, direct ou indirect,
+ * consequential or incidental, particulier ou général,
+ * arising from the use of the accessoire ou fortuit, résultant
+ * software. Neither the name de l'utilisation du logiciel. Ni
+ * of the National Research le nom du Conseil National de
+ * Council of Canada nor the Recherches du Canada ni les noms
+ * names of its contributors may de ses participants ne peuvent
+ * be used to endorse or promote être utilisés pour approuver ou
+ * products derived from this promouvoir les produits dérivés
+ * software without specific prior de ce logiciel sans autorisation
+ * written permission. préalable et particulière
+ * par écrit.
+ *
+ * This file is part of the Ce fichier fait partie du projet
+ * OpenCADC project. OpenCADC.
+ *
+ * OpenCADC is free software: OpenCADC est un logiciel libre ;
+ * you can redistribute it and/or vous pouvez le redistribuer ou le
+ * modify it under the terms of modifier suivant les termes de
+ * the GNU Affero General Public la “GNU Affero General Public
+ * License as published by the License” telle que publiée
+ * Free Software Foundation, par la Free Software Foundation
+ * either version 3 of the : soit la version 3 de cette
+ * License, or (at your option) licence, soit (à votre gré)
+ * any later version. toute version ultérieure.
+ *
+ * OpenCADC is distributed in the OpenCADC est distribué
+ * hope that it will be useful, dans l’espoir qu’il vous
+ * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE
+ * without even the implied GARANTIE : sans même la garantie
+ * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ
+ * or FITNESS FOR A PARTICULAR ni d’ADÉQUATION À UN OBJECTIF
+ * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence
+ * General Public License for Générale Publique GNU Affero
+ * more details. pour plus de détails.
+ *
+ * You should have received Vous devriez avoir reçu une
+ * a copy of the GNU Affero copie de la Licence Générale
+ * General Public License along Publique GNU Affero avec
+ * with OpenCADC. If not, see OpenCADC ; si ce n’est
+ * . pas le cas, consultez :
+ * .
+ *
+ *
+ ************************************************************************
+ */
+
+package org.opencadc.token;
+
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class TestTokenStore implements TokenStore {
+ private final Map cache = new ConcurrentHashMap<>();
+
+ @Override
+ public String put(Assets assets) {
+ final String newKey = UUID.randomUUID().toString();
+ put(newKey, assets);
+ return newKey;
+ }
+
+ @Override
+ public void put(String assetsKey, Assets assets) {
+ cache.put(assetsKey, assets);
+ }
+
+ @Override
+ public Assets get(String assetsKey) {
+ if (cache.containsKey(assetsKey)) {
+ return cache.get(assetsKey);
+ } else {
+ throw new NoSuchElementException("TEST");
+ }
+ }
+}
diff --git a/cadc-web-util/build.gradle b/cadc-web-util/build.gradle
index 1271fbc..be13b06 100644
--- a/cadc-web-util/build.gradle
+++ b/cadc-web-util/build.gradle
@@ -10,7 +10,7 @@ repositories {
mavenLocal()
}
-sourceCompatibility = 1.8
+sourceCompatibility = 11
group = 'org.opencadc'
version = '1.2.13'
diff --git a/settings.gradle b/settings.gradle
index 6de682b..6ed700f 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,2 @@
rootProject.name = 'opencadc-web'
-include('cadc-web-util', 'cadc-web-test')
+include('cadc-web-token', 'cadc-web-util', 'cadc-web-test')