diff --git a/.github/coveragereport/badge_branchcoverage.svg b/.github/coveragereport/badge_branchcoverage.svg index 8d2dba49..cbae7016 100644 --- a/.github/coveragereport/badge_branchcoverage.svg +++ b/.github/coveragereport/badge_branchcoverage.svg @@ -101,7 +101,7 @@ Coverage Coverage - 80%80% + 76.9%76.9% diff --git a/.github/coveragereport/badge_linecoverage.svg b/.github/coveragereport/badge_linecoverage.svg index 22764e15..84e25ed7 100644 --- a/.github/coveragereport/badge_linecoverage.svg +++ b/.github/coveragereport/badge_linecoverage.svg @@ -100,7 +100,7 @@ Coverage Coverage - 95.1%95.1% + 94.5%94.5% diff --git a/.github/coveragereport/badge_methodcoverage.svg b/.github/coveragereport/badge_methodcoverage.svg index 0f8b6dad..d449fc9f 100644 --- a/.github/coveragereport/badge_methodcoverage.svg +++ b/.github/coveragereport/badge_methodcoverage.svg @@ -102,7 +102,7 @@ Coverage - 93.6%93.6% + 93.1%93.1% diff --git a/.gitignore b/.gitignore index b227d28a..dad901df 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build **/bin **/.idea/ lib/ +.vscode diff --git a/build.gradle b/build.gradle index 17f7b11c..ce8634be 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ subprojects { } jacoco { - toolVersion = "0.8.7" + toolVersion = "0.8.11" } jacocoTestReport { diff --git a/buildSrc/src/main/groovy/cwms-data-api-client.java-conventions.gradle b/buildSrc/src/main/groovy/cwms-data-api-client.java-conventions.gradle index 7f6d82f6..5c89a058 100644 --- a/buildSrc/src/main/groovy/cwms-data-api-client.java-conventions.gradle +++ b/buildSrc/src/main/groovy/cwms-data-api-client.java-conventions.gradle @@ -22,6 +22,10 @@ configurations.implementation.extendsFrom(configurations.annotationProcessor) test { useJUnitPlatform() + // when passing --tests by default it needs to match all in all subjects + filter { + setFailOnNoMatchingTests(false) + } } javadoc { diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/AuthCodePkceTokenRequestBuilder.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/AuthCodePkceTokenRequestBuilder.java new file mode 100644 index 00000000..cbe0adbd --- /dev/null +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/AuthCodePkceTokenRequestBuilder.java @@ -0,0 +1,191 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package hec.army.usace.hec.cwbi.auth.http.client; + +import mil.army.usace.hec.cwms.http.client.HttpRequestBuilderImpl; +import mil.army.usace.hec.cwms.http.client.HttpRequestResponse; +import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; +import mil.army.usace.hec.cwms.http.client.request.HttpRequestExecutor; +import mil.army.usace.hec.cwms.http.client.request.QueryParameters; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Logger; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +/** + * Use Authorization Code + PKCE method to retrieve initial token set. + * + * If a desktop is available users's default Browser is opened with the given auth URL + * To complete the additional requirements. + */ +public final class AuthCodePkceTokenRequestBuilder extends TokenRequestBuilder { + private static final Logger LOGGER = Logger.getLogger(AuthCodePkceTokenRequestBuilder.class.getName()); + @Override + OAuth2Token retrieveToken() throws IOException { + + OAuth2Token retVal = null; + HttpServer server = null; + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + try { + byte[] verifierBytes = new byte[128]; + SecureRandom.getInstanceStrong().nextBytes(verifierBytes); + Base64.Encoder b64encoder = Base64.getUrlEncoder().withoutPadding(); + final String verifier = b64encoder.encodeToString(verifierBytes); + final String originalState = UUID.randomUUID().toString(); + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + final String challenge = b64encoder.encodeToString(md.digest(verifier.getBytes(StandardCharsets.US_ASCII))); + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + int port = server.getAddress().getPort(); + String host = server.getAddress().getHostName(); + + final CompletableFuture future = new CompletableFuture<>(); + + server.createContext("/", new HttpHandler() { + + @Override + public void handle(HttpExchange exchange) throws IOException { + Result ret = null; + + final String query = exchange.getRequestURI().getQuery(); + LOGGER.fine("Got auth server response." + query); + final QueryParameters parameters = QueryParameters.parse(query); + if (!parameters.get("error").isEmpty()) { + String error = parameters.get("error").get(0); + String errorDescription = parameters.get("error_description").get(0); + ret = Result.failure(error, errorDescription); + } else { + String code = parameters.get("code").get(0); + String state = parameters.get("state").get(0); + String session_state = parameters.get("session_state").get(0); + ret = Result.success(code ,state, session_state); + } + LOGGER.fine("Returning result back to thread."); + exchange.sendResponseHeaders(204, 0); + + future.complete(ret); + } + + }); + final String redirectUri = String.format("http://%s:%d", host, port); + final QueryParameters authParameters = QueryParameters.empty() + .set("grant_type", "code") + .set("client_id", getClientId()) + .set("scope", "openid profile") + .set("response_type", "code") + .set("code_challenge_method", "S256") + .set("code_challenge", challenge) + .set("redirect_uri", redirectUri) + .set("state", originalState); + String urlStr= String.format("%s?%s", getAuthUrl().getApiRoot(), authParameters.encode()); + // start server to listen + server.start(); + LOGGER.info("Handling Auth Request"); + LOGGER.finer("Auth Request URL: " + urlStr); + this.authCallBack.accept(URI.create(urlStr)); + + Result result = future.get(3, TimeUnit.MINUTES); // The user is now required to perform manual operations. + LOGGER.info("Retrieving Token."); + if (result.error != null) { + throw new IOException(String.format("Unable to login. %s : %s", result.error, result.errorDescription)); + } + if (!result.state.equals(originalState)) { + throw new IOException("Unable to continue login sequence, incorrect state value returned."); + } + final UrlEncodedFormData formData = new UrlEncodedFormData(); + formData.addClientId(getClientId()) + .addGrantType("authorization_code") + .addParameter("code_verifier", verifier) + .addScopes("openid", "profile") + .addParameter("redirect_uri", redirectUri) + .addParameter("state", result.state) + .addParameter("session_state", result.session_state) + .addParameter("code", result.code) + .addParameter("response_mode", "fragment") + .addParameter("response_type", "id_token token"); + + HttpRequestExecutor executor = + new HttpRequestBuilderImpl(getTokenUrl()) + .post() + .withBody(formData.buildEncodedString()) + .withMediaType(MEDIA_TYPE); + try (HttpRequestResponse response = executor.execute()) { + String body = response.getBody(); + if (body != null) { + retVal = OAuth2ObjectMapper.mapJsonToObject(body, OAuth2Token.class); + } + } + return retVal; + } catch (NoSuchAlgorithmException ex) { + throw new IOException("Unable to retrieve SecureRandom or Message Digest instance to generate verifier", ex); + } catch (InterruptedException | ExecutionException | TimeoutException ex) { + throw new IOException("Unable to form login sequence.", ex); + } + finally { + if (server != null) { + server.stop(0); + } + } + } + + private static class Result { + public final String code; + public final String state; + public final String session_state; + + public final String error; + public final String errorDescription; + + private Result(String code, String state, String session_state, String error, String errorDescription) { + this.code = code; + this.state = state; + this.session_state = session_state; + this.error = error; + this.errorDescription = errorDescription; + } + + public static Result success(String code, String state, String session_state) { + return new Result(code, state, session_state, null, null); + } + + public static Result failure(String error, String errorDescription) { + return new Result(null, null, null, error, errorDescription); + } + }; +} diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProvider.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProvider.java index 36e6a52b..33847549 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProvider.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProvider.java @@ -24,35 +24,55 @@ package hec.army.usace.hec.cwbi.auth.http.client; import hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager; + +import java.io.IOException; import java.util.Objects; + import javax.net.ssl.SSLSocketFactory; import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder; import mil.army.usace.hec.cwms.http.client.SslSocketData; +import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; +/** + * Suitable only for CWBI Keycloaks direct grant setup. + */ public final class CwbiAuthTokenProvider extends CwbiAuthTokenProviderBase { private final SSLSocketFactory sslSocketFactory; - private final String url; /** * Provider for OAuth2Tokens. * - * @param tokenUrl - URL we are fetching token from + * @param wellKnownUrl - URL we are retrieving configuration from * @param clientId - client name * @param sslSocketFactory - ssl socket factory */ - public CwbiAuthTokenProvider(String tokenUrl, String clientId, SSLSocketFactory sslSocketFactory) { - super(clientId); + public CwbiAuthTokenProvider(String wellKnownUrl, String clientId, SSLSocketFactory sslSocketFactory) { + super(clientId, wellKnownUrl); this.sslSocketFactory = Objects.requireNonNull(sslSocketFactory, "Missing required sslSocketFactory"); - this.url = Objects.requireNonNull(tokenUrl, "Missing required tokenUrl"); } @Override ApiConnectionInfo getUrl() { - return new ApiConnectionInfoBuilder(url) + return new ApiConnectionInfoBuilder(this.wellKnownUrl) + .withSslSocketData(new SslSocketData(sslSocketFactory, CwbiAuthTrustManager.getTrustManager())) + .build(); + } + + @Override + public ApiConnectionInfo getAuthUrl() { + // This is specific to CWBI Direct Grant so this replacement as-is is fine + return new ApiConnectionInfoBuilder(this.tokenUrl.getApiRoot().replace("identity", "identityc")) .withSslSocketData(new SslSocketData(sslSocketFactory, CwbiAuthTrustManager.getTrustManager())) .build(); } + @Override + public OAuth2Token newToken() throws IOException { + return new DirectGrantX509TokenRequestBuilder() + .withTokenUrl(getAuthUrl()) + .buildRequest().withClientId(clientId) + .fetchToken(); + } } diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProviderBase.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProviderBase.java index 9a093dea..7e1b5c55 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProviderBase.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProviderBase.java @@ -24,50 +24,35 @@ package hec.army.usace.hec.cwbi.auth.http.client; import java.io.IOException; -import java.util.Objects; + import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; -import mil.army.usace.hec.cwms.http.client.auth.OAuth2TokenProvider; -abstract class CwbiAuthTokenProviderBase implements OAuth2TokenProvider { - protected OAuth2Token oauth2Token; - protected final String clientId; +abstract class CwbiAuthTokenProviderBase extends OidcAuthTokenProvider { - protected CwbiAuthTokenProviderBase(String clientId) { - this.clientId = Objects.requireNonNull(clientId, "Missing required clientId"); + protected CwbiAuthTokenProviderBase(String clientId, String wellKnownUrl) { + super(clientId, wellKnownUrl); } abstract ApiConnectionInfo getUrl() throws IOException; - @Override - public synchronized void clear() { - oauth2Token = null; - } @Override public synchronized OAuth2Token getToken() throws IOException { - if (oauth2Token == null) { - oauth2Token = newToken(); + if (token == null) { + token = newToken(); } - return oauth2Token; - } - - @Override - public OAuth2Token newToken() throws IOException { - return new DirectGrantX509TokenRequestBuilder() - .withUrl(getUrl()) - .withClientId(clientId) - .fetchToken(); + return token; } @Override public synchronized OAuth2Token refreshToken() throws IOException { - OAuth2Token token = new RefreshTokenRequestBuilder() - .withRefreshToken(oauth2Token.getRefreshToken()) - .withUrl(getUrl()) + OAuth2Token newToken = new RefreshTokenRequestBuilder() + .withRefreshToken(token.getRefreshToken()) + .withUrl(tokenUrl) .withClientId(clientId) .fetchToken(); - oauth2Token = token; + token = newToken; return token; } diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DirectGrantX509TokenRequestBuilder.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DirectGrantX509TokenRequestBuilder.java index faface92..74224222 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DirectGrantX509TokenRequestBuilder.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DirectGrantX509TokenRequestBuilder.java @@ -23,6 +23,7 @@ */ package hec.army.usace.hec.cwbi.auth.http.client; +import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; import mil.army.usace.hec.cwms.http.client.HttpRequestBuilderImpl; import mil.army.usace.hec.cwms.http.client.HttpRequestResponse; import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; @@ -30,7 +31,7 @@ import java.io.IOException; -public final class DirectGrantX509TokenRequestBuilder extends TokenRequestBuilder { +public final class DirectGrantX509TokenRequestBuilder extends TokenRequestBuilder { @Override OAuth2Token retrieveToken() throws IOException { @@ -43,7 +44,7 @@ OAuth2Token retrieveToken() throws IOException { .addUsername("") .buildEncodedString(); HttpRequestExecutor executor = - new HttpRequestBuilderImpl(getUrl()) + new HttpRequestBuilderImpl(getTokenUrl()) .post() .withBody(formBody) .withMediaType(MEDIA_TYPE); diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DiscoveredCwbiAuthTokenProvider.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DiscoveredCwbiAuthTokenProvider.java deleted file mode 100644 index 7c51385a..00000000 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DiscoveredCwbiAuthTokenProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package hec.army.usace.hec.cwbi.auth.http.client; - -import java.io.IOException; -import java.util.Objects; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; - -public final class DiscoveredCwbiAuthTokenProvider extends CwbiAuthTokenProviderBase -{ - private final TokenUrlDiscoveryService tokenUrlDiscoveryService; - private ApiConnectionInfo url; - - public DiscoveredCwbiAuthTokenProvider(String clientId, TokenUrlDiscoveryService tokenUrlDiscoveryService) - { - super(clientId); - this.tokenUrlDiscoveryService = Objects.requireNonNull(tokenUrlDiscoveryService, "Missing required tokenUrlDiscoveryService"); - } - - @Override - synchronized ApiConnectionInfo getUrl() throws IOException { - if(url == null) - { - url = tokenUrlDiscoveryService.discoverTokenUrl(); - } - return url; - } -} diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OidcAuthTokenProvider.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OidcAuthTokenProvider.java new file mode 100644 index 00000000..fc6eb352 --- /dev/null +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OidcAuthTokenProvider.java @@ -0,0 +1,126 @@ +package hec.army.usace.hec.cwbi.auth.http.client; + +import java.io.IOException; +import java.net.URI; +import java.util.Objects; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; + +import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; +import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder; +import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; +import mil.army.usace.hec.cwms.http.client.auth.OAuth2TokenProvider; + +/** + * Handle generic OIDC auth based on configuration elements in the .well-known/openid-configuration + * values. + * + * Defaults to using Authorization Code + PKCE. + * Support should be provided to support alternative flows as a user-at-login decision point. + */ +public class OidcAuthTokenProvider implements OAuth2TokenProvider { + + protected final String clientId; + protected final String wellKnownUrl; + protected final ApiConnectionInfo tokenUrl; + protected final ApiConnectionInfo authUrl; + protected OAuth2Token token = null; + // Default to open browser or print to console for usage, but allow overriding for testing and + // other usages. + private Consumer authCallback = TokenRequestBuilder.BROWSER_OR_CONSOLE_AUTH_CALLBACK; + + public OidcAuthTokenProvider(String clientId, String wellKnownUrl) { + this.clientId = Objects.requireNonNull(clientId, "Missing required client id."); + this.wellKnownUrl = Objects.requireNonNull(wellKnownUrl, "Missing required well known Url."); + + OpenIdTokenController controller = new OpenIdTokenController() { + + @Override + public String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) throws IOException { + return wellKnownUrl; // we already have it. + } + + }; + ApiConnectionInfo info = new ApiConnectionInfoBuilder(wellKnownUrl).build(); + String what = "auth"; + try { + this.authUrl = controller.retrieveAuthUrl(info, null); + what = "token"; + this.tokenUrl = controller.retrieveTokenUrl(info, null); + // TODO: process appropriate extensions to determine things like "kc_idp_hint" + } catch (IOException ex) { + throw new CompletionException("Unable to return " + what + " URL", ex); + } + } + + @Override + public void clear() { + synchronized (this) { + this.token = null; + } + } + + @Override + public Consumer getAuthCallback() { + return authCallback; + } + + @Override + public void setAuthCallback(Consumer authCallback) { + this.authCallback = authCallback; + } + + @Override + public OAuth2Token getToken() throws IOException { + synchronized(this) { + if (token == null) { + token = newToken(); + } + return token; + } + } + + @Override + public OAuth2Token refreshToken() throws IOException { + synchronized (this) { + OAuth2Token newToken = new RefreshTokenRequestBuilder() + .withRefreshToken(token.getRefreshToken()) + .withUrl(tokenUrl) + .withClientId(clientId) + .fetchToken(); + token = newToken; + return token; + } + } + + @Override + public OAuth2Token newToken() throws IOException { + synchronized (this) { + /** + * It may make sense to allow something to override this usage, however that + * *should* be a user setting. So like additional drop down or something in the gui. + * There are various notes about it in different sections for discussion. + */ + token = new AuthCodePkceTokenRequestBuilder() + .withAuthUrl(authUrl) + .withTokenUrl(tokenUrl) + .withAuthCallback(authCallback) + .buildRequest() + .withClientId(clientId) + .fetchToken(); + return token; + } + + } + + @Override + public ApiConnectionInfo getAuthUrl() { + return authUrl; + } + + @Override + public ApiConnectionInfo getTokenUrl() { + return tokenUrl; + } + +} diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OpenIdTokenController.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OpenIdTokenController.java index de40897c..2dd54ebe 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OpenIdTokenController.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/OpenIdTokenController.java @@ -12,20 +12,55 @@ public abstract class OpenIdTokenController { static final String ACCEPT_HEADER = "application/json"; private static final String TOKEN_ENDPOINT_KEY = "token_endpoint"; - protected abstract String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) throws IOException; + private static final String AUTH_ENDPOINT_KEY = "authorization_endpoint"; + + + private String authEndpoint = null; + private String tokenEndpoint = null; + + /** + * Retrieve json text of the .wellknown/openid-configuration + * @param apiConnectionInfo + * @return + * @throws IOException + */ + public abstract String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) throws IOException; + public final ApiConnectionInfo retrieveTokenUrl(ApiConnectionInfo apiConnectionInfo, SslSocketData sslSocketData) throws IOException { - String wellKnownEndpoint = retrieveWellKnownEndpoint(apiConnectionInfo); - ApiConnectionInfo wellKnownApiConnectionInfo = new ApiConnectionInfoBuilder(wellKnownEndpoint) + if (tokenEndpoint == null) { + String wellKnownEndpoint = retrieveWellKnownEndpoint(apiConnectionInfo); + + ApiConnectionInfo wellKnownApiConnectionInfo = new ApiConnectionInfoBuilder(wellKnownEndpoint) + .withSslSocketData(sslSocketData) + .build(); + HttpRequestExecutor executor = new HttpRequestBuilderImpl(wellKnownApiConnectionInfo) + .get() + .withMediaType(ACCEPT_HEADER); + try (HttpRequestResponse response = executor.execute()) { + tokenEndpoint = OAuth2ObjectMapper.getValueForKey(response.getBody(), TOKEN_ENDPOINT_KEY); + } + } + return new ApiConnectionInfoBuilder(tokenEndpoint) .withSslSocketData(sslSocketData) .build(); - HttpRequestExecutor executor = new HttpRequestBuilderImpl(wellKnownApiConnectionInfo) - .get() - .withMediaType(ACCEPT_HEADER); - try (HttpRequestResponse response = executor.execute()) { - String tokenEndpoint = OAuth2ObjectMapper.getValueForKey(response.getBody(), TOKEN_ENDPOINT_KEY); - return new ApiConnectionInfoBuilder(tokenEndpoint) + } + + public final ApiConnectionInfo retrieveAuthUrl(ApiConnectionInfo apiConnectionInfo, SslSocketData sslSocketData) throws IOException { + + if (authEndpoint == null) { + String wellKnownEndpoint = retrieveWellKnownEndpoint(apiConnectionInfo); + ApiConnectionInfo wellKnownApiConnectionInfo = new ApiConnectionInfoBuilder(wellKnownEndpoint) .withSslSocketData(sslSocketData) .build(); + HttpRequestExecutor executor = new HttpRequestBuilderImpl(wellKnownApiConnectionInfo) + .get() + .withMediaType(ACCEPT_HEADER); + try (HttpRequestResponse response = executor.execute()) { + authEndpoint = OAuth2ObjectMapper.getValueForKey(response.getBody(), AUTH_ENDPOINT_KEY); + } } + return new ApiConnectionInfoBuilder(authEndpoint) + .withSslSocketData(sslSocketData) + .build(); } } diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestBuilder.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestBuilder.java index 6e319f6a..009d93a0 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestBuilder.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestBuilder.java @@ -1,6 +1,5 @@ package hec.army.usace.hec.cwbi.auth.http.client; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; import mil.army.usace.hec.cwms.http.client.HttpRequestBuilderImpl; import mil.army.usace.hec.cwms.http.client.HttpRequestResponse; import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; @@ -19,12 +18,12 @@ public final class RefreshTokenRequestBuilder implements RefreshTokenRequestFlue * @return Builder for http request */ @Override - public TokenRequestFluentBuilder withRefreshToken(String refreshToken) { + public TokenRequestFluentBuilder> withRefreshToken(String refreshToken) { this.refreshToken = Objects.requireNonNull(refreshToken, "Missing required refresh token"); return new RefreshTokenRequestExecutor(); } - class RefreshTokenRequestExecutor extends TokenRequestBuilder { + class RefreshTokenRequestExecutor extends TokenRequestBuilder { @Override OAuth2Token retrieveToken() throws IOException { diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestFluentBuilder.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestFluentBuilder.java index 18976a64..c38a06c2 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestFluentBuilder.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/RefreshTokenRequestFluentBuilder.java @@ -1,5 +1,5 @@ package hec.army.usace.hec.cwbi.auth.http.client; public interface RefreshTokenRequestFluentBuilder { - TokenRequestFluentBuilder withRefreshToken(String refreshToken); + TokenRequestFluentBuilder> withRefreshToken(String refreshToken); } diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestBuilder.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestBuilder.java index 14989734..b6923c64 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestBuilder.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestBuilder.java @@ -23,23 +23,70 @@ */ package hec.army.usace.hec.cwbi.auth.http.client; +import java.awt.Desktop; +import java.awt.Desktop.Action; import java.io.IOException; +import java.net.URI; import java.util.Objects; +import java.util.function.Consumer; + import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; -abstract class TokenRequestBuilder implements TokenRequestFluentBuilder { +abstract class TokenRequestBuilder implements TokenRequestFluentBuilder> { + public static final Consumer BROWSER_OR_CONSOLE_AUTH_CALLBACK = u -> { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Action.BROWSE)) { + try { + Desktop.getDesktop().browse(u); + } catch (IOException ex) { + throw new RuntimeException("Unable to open browser", ex); + } + } else { + System.out.println(String.format("Paste the following into a browser to continue login: %s", u.toString())); + } + }; static final String MEDIA_TYPE = "application/x-www-form-urlencoded"; private ApiConnectionInfo url; + private ApiConnectionInfo authUrl; + private ApiConnectionInfo tokenUrl; private String clientId; + protected Consumer authCallBack = (u) -> {}; // by default do nothing. abstract OAuth2Token retrieveToken() throws IOException; + /** + * Method used method the auth and token URL are the same. + * @return + * @deprecated implementations, even when they are the same, should use the individual auth/token methods. + */ + @Deprecated(forRemoval = true) ApiConnectionInfo getUrl() { return url; } + /** + * Retrieve the specific Auth endpoint URL. + * @return + */ + ApiConnectionInfo getAuthUrl() { + return authUrl; + } + + /** + * Retrieve the specific Token endpiont URL. + * @return + */ + ApiConnectionInfo getTokenUrl() { + return tokenUrl; + } + + @Override + public TokenRequestBuilder withAuthCallback(Consumer authCallback) { + this.authCallBack = authCallback; + return this; + } + String getClientId() { return clientId; } @@ -50,6 +97,24 @@ public RequestClientId withUrl(ApiConnectionInfo url) { return new RequestClientIdImpl(); } + @Override + public RequestClientId buildRequest() { + return new RequestClientIdImpl(); + } + + @Override + public TokenRequestBuilder withAuthUrl(ApiConnectionInfo url) { + this.authUrl = url; + return this; + } + + @Override + public TokenRequestBuilder withTokenUrl(ApiConnectionInfo url) { + this.tokenUrl = url; + return this; + } + + private class RequestClientIdImpl implements RequestClientId { @Override diff --git a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestFluentBuilder.java b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestFluentBuilder.java index 53555786..b6eb52b9 100644 --- a/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestFluentBuilder.java +++ b/cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/TokenRequestFluentBuilder.java @@ -23,9 +23,49 @@ */ package hec.army.usace.hec.cwbi.auth.http.client; +import java.net.URI; +import java.util.function.Consumer; + import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -public interface TokenRequestFluentBuilder { +public interface TokenRequestFluentBuilder> { + /** + * If given auth method uses a single URL. + * @param url + * @return + * @deprecated even for implementations, like direct grant/resource owner password credentials + * should use the individual endpoints in the appropriate sections to avoid configuration + * details that are too specific but filter up among the usage. + */ + @Deprecated(forRemoval = true) RequestClientId withUrl(ApiConnectionInfo url); + + /** + * Create object for next step in auth. + * @return + */ + RequestClientId buildRequest(); + + /** + * set specific Auth URL endpoint. + * @param url + * @return + */ + TokenRequestFluentBuilder withAuthUrl(ApiConnectionInfo url); + + /** + * set specific Token URL endpoint. + * @param url + * @return + */ + TokenRequestFluentBuilder withTokenUrl(ApiConnectionInfo url); + + /** + * For methods where an external step is required to finish authentication + * pass in desired operation + * @param authCallback + * @return + */ + TokenRequestFluentBuilder withAuthCallback(Consumer authCallback); } diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockCwbiAuthTokenProvider.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockCwbiAuthTokenProvider.java index 7687d325..6a53a9bd 100644 --- a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockCwbiAuthTokenProvider.java +++ b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockCwbiAuthTokenProvider.java @@ -44,14 +44,14 @@ public class MockCwbiAuthTokenProvider extends CwbiAuthTokenProviderBase { * @param sslSocketFactory - ssl socket factory */ public MockCwbiAuthTokenProvider(String url, String clientId, SSLSocketFactory sslSocketFactory) { - super(clientId); + super(clientId, url); this.sslSocketFactory = Objects.requireNonNull(sslSocketFactory, "Missing required sslSocketFactory"); this.url = url; } //used to manually set token for testing void setOAuth2Token(OAuth2Token token) { - oauth2Token = token; + this.token = token; } @Override diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockDiscoveredCwbiAuthTokenProvider.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockDiscoveredCwbiAuthTokenProvider.java deleted file mode 100644 index 18f04482..00000000 --- a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/MockDiscoveredCwbiAuthTokenProvider.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package hec.army.usace.hec.cwbi.auth.http.client; - -import java.io.IOException; -import java.util.Objects; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; - -public class MockDiscoveredCwbiAuthTokenProvider extends CwbiAuthTokenProviderBase { - - private final TokenUrlDiscoveryService tokenUrlDiscoveryService; - private ApiConnectionInfo url; - - /** - * Provider for OAuth2Tokens. - * - * @param clientId - client name - * @param tokenUrlDiscoveryService - service to discover the token URL - */ - public MockDiscoveredCwbiAuthTokenProvider(String clientId, TokenUrlDiscoveryService tokenUrlDiscoveryService) { - super(clientId); - this.tokenUrlDiscoveryService = Objects.requireNonNull(tokenUrlDiscoveryService, "Missing required tokenUrlDiscoveryService"); - } - - //used to manually set token for testing - void setOAuth2Token(OAuth2Token token) { - oauth2Token = token; - } - - //package scoped for testing - @Override - synchronized ApiConnectionInfo getUrl() throws IOException { - if(url == null) - { - url = tokenUrlDiscoveryService.discoverTokenUrl(); - } - return url; - } - - //package scoped for testing - TokenUrlDiscoveryService getDiscoveryService() { - return tokenUrlDiscoveryService; - } -} diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestCwbiTokenProvider.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestCwbiTokenProvider.java index 9cfdf837..c00d516e 100644 --- a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestCwbiTokenProvider.java +++ b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestCwbiTokenProvider.java @@ -28,6 +28,7 @@ import java.util.Collections; import javax.net.ssl.KeyManager; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.File; import java.io.IOException; @@ -38,15 +39,25 @@ import java.nio.file.Path; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.logging.Logger; + import javax.net.ssl.SSLSocketFactory; import mil.army.usace.hec.cwms.http.client.MockHttpServer; import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder; import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; +import mil.army.usace.hec.cwms.http.client.request.QueryParameters; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; + import org.junit.jupiter.api.AfterEach; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -73,34 +84,70 @@ void tearDown() throws IOException { } ApiConnectionInfo buildConnectionInfo() { - String baseUrl = String.format("http://localhost:%s", mockHttpServer.getPort()); + String baseUrl = String.format("http://localhost:%s/openid-configuration", mockHttpServer.getPort()); return new ApiConnectionInfoBuilder(baseUrl).build(); } - protected void launchMockServerWithResource(String resource) throws IOException { + private String getResource(String resource) throws IOException { URL resourceUrl = getClass().getClassLoader().getResource(resource); if (resourceUrl == null) { throw new IOException("Failed to get resource: " + resource); } Path path = new File(resourceUrl.getFile()).toPath(); String collect = String.join("\n", Files.readAllLines(path)); - mockHttpServer.enqueue(collect); + return collect; + } + + protected void launchMockServerWithResource(String resource) throws IOException { + mockHttpServer.getMockServer().setDispatcher(new Dispatcher() { + private final Logger LOGGER = Logger.getLogger(TestOidcTokenProvider.class.getName()); + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + final HttpUrl url = request.getRequestUrl(); + final String path = url.encodedPath(); + LOGGER.fine("Request for: " + url.toString()); + LOGGER.fine("Path: " + path); + + try { + if (path.endsWith("openid-configuration")) { + return new MockResponse().setBody(getResource("openIdConfig2.json") + .replace("PORT", ""+mockHttpServer.getPort())); + } + else if (path.endsWith("/auth")) { + throw new IOException("Endpoint should not be called. Request was " + url.toString()); + //fail("CwbiTokenProvider uses direct grant and should not call the /auth endpoint."); + } + else if (path.endsWith("/token")) { + return new MockResponse().setBody(getResource("oauth2token.json")); + } + } catch (IOException ex) { + fail("Couldn't process mocked request", ex); + } + return new MockResponse().setResponseCode(404).setBody("Request not mocked."); + } + }); mockHttpServer.start(); } @Test void testBuildTokenProvider() throws IOException { + String resource = "oauth2token.json"; + launchMockServerWithResource(resource); SSLSocketFactory sslSocketFactory = CwbiAuthSslSocketFactory.buildSSLSocketFactory( Collections.singletonList(getTestKeyManager())); - CwbiAuthTokenProvider tokenProvider = new CwbiAuthTokenProvider(TOKEN_URL, "cumulus", sslSocketFactory); - assertEquals(TOKEN_URL, tokenProvider.getUrl().getApiRoot()); + String url = buildConnectionInfo().getApiRoot(); + CwbiAuthTokenProvider tokenProvider = new CwbiAuthTokenProvider(url, "cumulus", sslSocketFactory); + assertEquals(url, tokenProvider.getUrl().getApiRoot()); assertEquals("cumulus", tokenProvider.getClientId()); } @Test - void testNulls() { - assertThrows(NullPointerException.class, () -> new CwbiAuthTokenProvider(TOKEN_TEST_URL, "cumulus", null)); - assertThrows(NullPointerException.class, () -> new CwbiAuthTokenProvider(TOKEN_TEST_URL, null, getTestSslSocketFactory())); + void testNulls() throws IOException { + String resource = "oauth2token.json"; + launchMockServerWithResource(resource); + String url = buildConnectionInfo().getApiRoot(); + assertThrows(NullPointerException.class, () -> new CwbiAuthTokenProvider(url, "cumulus", null)); + assertThrows(NullPointerException.class, () -> new CwbiAuthTokenProvider(url, null, getTestSslSocketFactory())); assertThrows(NullPointerException.class, () -> new CwbiAuthTokenProvider(null, "cumulus", getTestSslSocketFactory())); } @@ -128,13 +175,7 @@ void testClear() throws IOException { String resource = "oauth2token.json"; launchMockServerWithResource(resource); String url = buildConnectionInfo().getApiRoot(); - MockCwbiAuthTokenProvider tokenProvider = new MockCwbiAuthTokenProvider(url, "cumulus", getTestSslSocketFactory()); - OAuth2Token token = new OAuth2Token(); - token.setAccessToken("abc123"); - token.setTokenType("Bearer"); - token.setExpiresIn(3600); - token.setRefreshToken("123abc"); - tokenProvider.setOAuth2Token(token); + CwbiAuthTokenProvider tokenProvider = new CwbiAuthTokenProvider(url, "cumulus", getTestSslSocketFactory()); OAuth2Token token1 = tokenProvider.getToken(); OAuth2Token token2 = tokenProvider.getToken(); assertSame(token1, token2); @@ -147,13 +188,9 @@ void testRefreshToken() throws IOException { String resource = "oauth2token.json"; launchMockServerWithResource(resource); String url = buildConnectionInfo().getApiRoot(); - MockCwbiAuthTokenProvider tokenProvider = new MockCwbiAuthTokenProvider(url, "cumulus", getTestSslSocketFactory()); - OAuth2Token token = new OAuth2Token(); - token.setAccessToken("abc123"); - token.setTokenType("Bearer"); - token.setExpiresIn(3600); - token.setRefreshToken("123abc"); - tokenProvider.setOAuth2Token(token); + CwbiAuthTokenProvider tokenProvider = new CwbiAuthTokenProvider(url, "cumulus", getTestSslSocketFactory()); + OAuth2Token token = tokenProvider.getToken(); + assertNotNull(token, "Failed to retrieve initial token."); OAuth2Token refreshedToken = tokenProvider.refreshToken(); assertEquals("MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", refreshedToken.getAccessToken()); @@ -164,10 +201,13 @@ void testRefreshToken() throws IOException { } @Test - void testConstructor() { + void testConstructor() throws IOException { + String resource = "oauth2token.json"; + launchMockServerWithResource(resource); + String url = buildConnectionInfo().getApiRoot(); SSLSocketFactory sslSocketFactory = getTestSslSocketFactory(); - MockCwbiAuthTokenProvider tokenProvider = new MockCwbiAuthTokenProvider("test.com", "clientId", sslSocketFactory); - assertEquals("test.com", tokenProvider.getUrl().getApiRoot()); + MockCwbiAuthTokenProvider tokenProvider = new MockCwbiAuthTokenProvider(url, "clientId", sslSocketFactory); + assertEquals(url, tokenProvider.getUrl().getApiRoot()); assertEquals("clientId", tokenProvider.getClientId()); assertEquals(sslSocketFactory, tokenProvider.getSslSocketFactory()); } diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDirectGrantX509TokenRequestBuilder.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDirectGrantX509TokenRequestBuilder.java index 7bedfbda..f874609b 100644 --- a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDirectGrantX509TokenRequestBuilder.java +++ b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDirectGrantX509TokenRequestBuilder.java @@ -30,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; import java.io.File; import java.io.IOException; @@ -38,10 +39,16 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.util.logging.Logger; + import javax.net.ssl.SSLSocketFactory; import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + import org.junit.jupiter.api.Test; class TestDirectGrantX509TokenRequestBuilder { @@ -52,7 +59,7 @@ void testRetrieveTokenMissingParams() { SslSocketData sslSocketData = new SslSocketData(getTestSslSocketFactory(), CwbiAuthTrustManager.getTrustManager()); assertThrows(NullPointerException.class, () -> { OAuth2Token token = new DirectGrantX509TokenRequestBuilder() - .withUrl(new ApiConnectionInfoBuilder("https://test.com") + .withUrl(new ApiConnectionInfoBuilder("https://test.com/openid-configuration") .withSslSocketData(sslSocketData) .build()) .withClientId(null) @@ -72,13 +79,36 @@ void testDirectGrantX509TokenRequestBuilder() throws IOException { try (MockWebServer mockWebServer = new MockWebServer()) { SslSocketData sslSocketData = new SslSocketData(getTestSslSocketFactory(), CwbiAuthTrustManager.getTrustManager()); String body = readJsonFile(); - mockWebServer.enqueue(new MockResponse().setBody(body).setResponseCode(200)); + mockWebServer.setDispatcher(new Dispatcher() { + private final Logger LOGGER = Logger.getLogger(TestOidcTokenProvider.class.getName()); + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + final HttpUrl url = request.getRequestUrl(); + final String path = url.encodedPath(); + LOGGER.fine("Request for: " + url.toString()); + LOGGER.fine("Path: " + path); + + + if (path.endsWith("openid-configuration")) { + fail("Using Direct Grant Request builder directly should not invoke the configuration request"); + } + else if (path.endsWith("/auth")) { + fail("CwbiTokenProvider uses direct grant and should not call the /auth endpoint."); + } + else if (path.endsWith("/token")) { + return new MockResponse().setBody(body); + } + + return new MockResponse().setResponseCode(404).setBody("Request not mocked."); + } + }); mockWebServer.start(); - String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort()); + String baseUrl = String.format("http://localhost:%s/token", mockWebServer.getPort()); OAuth2Token token = new DirectGrantX509TokenRequestBuilder() - .withUrl(new ApiConnectionInfoBuilder(baseUrl) + .withTokenUrl(new ApiConnectionInfoBuilder(baseUrl) .withSslSocketData(sslSocketData) .build()) + .buildRequest() .withClientId("cumulus") .fetchToken(); assertNotNull(token); diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDiscoveredCwbiTokenProvider.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDiscoveredCwbiTokenProvider.java deleted file mode 100644 index 2f03869d..00000000 --- a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestDiscoveredCwbiTokenProvider.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package hec.army.usace.hec.cwbi.auth.http.client; - -import hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager; -import java.io.File; -import java.io.IOException; -import java.net.InetAddress; -import java.net.Socket; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import javax.net.ssl.SSLSocketFactory; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder; -import mil.army.usace.hec.cwms.http.client.MockHttpServer; -import mil.army.usace.hec.cwms.http.client.SslSocketData; -import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; -import org.junit.jupiter.api.AfterEach; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class TestDiscoveredCwbiTokenProvider { - - static MockHttpServer mockHttpServer; - - static ExecutorService executorService; - - @BeforeAll - static void setUpExecutorService() { - executorService = Executors.newFixedThreadPool(1); - } - - @BeforeEach - void setUp() throws IOException { - mockHttpServer = MockHttpServer.create(); - } - - @AfterEach - void tearDown() throws IOException { - mockHttpServer.shutdown(); - } - - ApiConnectionInfo buildConnectionInfo() { - SSLSocketFactory sslSocketFactory = getTestSslSocketFactory(); - SslSocketData sslSocketData = new SslSocketData(sslSocketFactory, CwbiAuthTrustManager.getTrustManager()); - String baseUrl = String.format("http://localhost:%s", mockHttpServer.getPort()); - return new ApiConnectionInfoBuilder(baseUrl) - .withSslSocketData(sslSocketData) - .build(); - } - - protected void launchMockServerWithResource(String resource) throws IOException { - URL resourceUrl = getClass().getClassLoader().getResource(resource); - if (resourceUrl == null) { - throw new IOException("Failed to get resource: " + resource); - } - Path path = new File(resourceUrl.getFile()).toPath(); - String collect = String.join("\n", Files.readAllLines(path)); - mockHttpServer.enqueue(collect); - mockHttpServer.start(); - } - - @Test - void testBuildTokenProvider() throws IOException { - ApiConnectionInfo tokenUrl = buildConnectionInfo(); - MockTokenUrlDiscoveryService tokenUrlDiscoveryService = new MockTokenUrlDiscoveryService(tokenUrl); - DiscoveredCwbiAuthTokenProvider tokenProvider = new DiscoveredCwbiAuthTokenProvider("cumulus", tokenUrlDiscoveryService); - assertEquals(tokenUrl.getApiRoot(), tokenProvider.getUrl().getApiRoot()); - assertEquals("cumulus", tokenProvider.getClientId()); - } - - @Test - void testNulls() { - assertThrows(NullPointerException.class, () -> new DiscoveredCwbiAuthTokenProvider("cumulus", null)); - assertThrows(NullPointerException.class, () -> new DiscoveredCwbiAuthTokenProvider(null, new MockTokenUrlDiscoveryService(new ApiConnectionInfoBuilder("").build()))); - } - - @Test - void testGetToken() throws IOException { - String resource = "oauth2token.json"; - launchMockServerWithResource(resource); - ApiConnectionInfo tokenUrl = buildConnectionInfo(); - MockTokenUrlDiscoveryService tokenUrlDiscoveryService = new MockTokenUrlDiscoveryService(tokenUrl); - DiscoveredCwbiAuthTokenProvider tokenProvider = new DiscoveredCwbiAuthTokenProvider("cumulus", tokenUrlDiscoveryService); - OAuth2Token token = tokenProvider.getToken(); - assertEquals("MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", token.getAccessToken()); - assertEquals("Bearer", token.getTokenType()); - assertEquals(3600, token.getExpiresIn()); - assertEquals("IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", token.getRefreshToken()); - assertEquals("create", token.getScope()); - } - - @Test - void testClear() throws IOException { - String resource = "oauth2token.json"; - launchMockServerWithResource(resource); - ApiConnectionInfo tokenUrl = buildConnectionInfo(); - MockTokenUrlDiscoveryService tokenUrlDiscoveryService = new MockTokenUrlDiscoveryService(tokenUrl); - MockDiscoveredCwbiAuthTokenProvider tokenProvider = new MockDiscoveredCwbiAuthTokenProvider("cumulus", tokenUrlDiscoveryService); - OAuth2Token token = new OAuth2Token(); - token.setAccessToken("abc123"); - token.setTokenType("Bearer"); - token.setExpiresIn(3600); - token.setRefreshToken("123abc"); - tokenProvider.setOAuth2Token(token); - OAuth2Token token1 = tokenProvider.getToken(); - OAuth2Token token2 = tokenProvider.getToken(); - assertSame(token1, token2); - tokenProvider.clear(); - assertNotSame(token1, tokenProvider.getToken()); - } - - @Test - void testRefreshToken() throws IOException { - String resource = "oauth2token.json"; - launchMockServerWithResource(resource); - ApiConnectionInfo tokenUrl = buildConnectionInfo(); - MockTokenUrlDiscoveryService tokenUrlDiscoveryService = new MockTokenUrlDiscoveryService(tokenUrl); - MockDiscoveredCwbiAuthTokenProvider tokenProvider = new MockDiscoveredCwbiAuthTokenProvider("cumulus", tokenUrlDiscoveryService); - OAuth2Token token = new OAuth2Token(); - token.setAccessToken("abc123"); - token.setTokenType("Bearer"); - token.setExpiresIn(3600); - token.setRefreshToken("123abc"); - tokenProvider.setOAuth2Token(token); - - OAuth2Token refreshedToken = tokenProvider.refreshToken(); - assertEquals("MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", refreshedToken.getAccessToken()); - assertEquals("Bearer", refreshedToken.getTokenType()); - assertEquals(3600, refreshedToken.getExpiresIn()); - assertEquals("IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", refreshedToken.getRefreshToken()); - assertEquals("create", refreshedToken.getScope()); - } - - @Test - void testConstructor() throws IOException { - SSLSocketFactory sslSocketFactory = getTestSslSocketFactory(); - ApiConnectionInfo tokenUrl = new ApiConnectionInfoBuilder("test.com") - .withSslSocketData(new SslSocketData(sslSocketFactory, CwbiAuthTrustManager.getTrustManager())) - .build(); - MockTokenUrlDiscoveryService tokenUrlDiscoveryService = new MockTokenUrlDiscoveryService(tokenUrl); - MockDiscoveredCwbiAuthTokenProvider tokenProvider = new MockDiscoveredCwbiAuthTokenProvider("clientId", tokenUrlDiscoveryService); - assertEquals("test.com", tokenProvider.getUrl().getApiRoot()); - assertEquals("clientId", tokenProvider.getClientId()); - assertSame(tokenUrlDiscoveryService, tokenProvider.getDiscoveryService()); - } - - private SSLSocketFactory getTestSslSocketFactory() { - return new SSLSocketFactory() { - @Override - public String[] getDefaultCipherSuites() { - return new String[0]; - } - - @Override - public String[] getSupportedCipherSuites() { - return new String[0]; - } - - @Override - public Socket createSocket(Socket socket, String s, int i, boolean b) { - return null; - } - - @Override - public Socket createSocket(String s, int i) { - return null; - } - - @Override - public Socket createSocket(String s, int i, InetAddress inetAddress, int i1) { - return null; - } - - @Override - public Socket createSocket(InetAddress inetAddress, int i) { - return null; - } - - @Override - public Socket createSocket(InetAddress inetAddress, int i, InetAddress inetAddress1, int i1) { - return null; - } - }; - } - - private static class MockTokenUrlDiscoveryService implements TokenUrlDiscoveryService { - private final ApiConnectionInfo tokenUrl; - - private MockTokenUrlDiscoveryService(ApiConnectionInfo tokenUrl) { - this.tokenUrl = tokenUrl; - } - - @Override - public ApiConnectionInfo discoverTokenUrl() { - return tokenUrl; - } - } -} diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOidcTokenProvider.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOidcTokenProvider.java new file mode 100644 index 00000000..7660d9cb --- /dev/null +++ b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOidcTokenProvider.java @@ -0,0 +1,166 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package hec.army.usace.hec.cwbi.auth.http.client; + +import static hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager.TOKEN_TEST_URL; +import static hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager.TOKEN_URL; +import java.util.Collections; +import javax.net.ssl.KeyManager; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.awt.Desktop; +import java.awt.Desktop.Action; +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import javax.net.ssl.SSLSocketFactory; +import mil.army.usace.hec.cwms.http.client.MockHttpServer; +import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; +import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder; +import mil.army.usace.hec.cwms.http.client.HttpRequestBuilderImpl; +import mil.army.usace.hec.cwms.http.client.HttpRequestResponse; +import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token; +import mil.army.usace.hec.cwms.http.client.request.HttpRequestExecutor; +import mil.army.usace.hec.cwms.http.client.request.QueryParameters; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; + +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestOidcTokenProvider { + private static final Logger LOGGER = Logger.getLogger(TestOidcTokenProvider.class.getName()); + static MockHttpServer mockCdaServer; + static MockHttpServer mockAuthServer; + + @BeforeEach + void setUp() throws IOException { + mockCdaServer = MockHttpServer.create(); + mockAuthServer = MockHttpServer.create(); + mockCdaServer.start(); + mockAuthServer.start(); + } + + @AfterEach + void tearDown() throws IOException { + mockCdaServer.shutdown(); + mockAuthServer.shutdown(); + } + + ApiConnectionInfo buildCdaInfo() { + String baseUrl = String.format("http://localhost:%s", mockCdaServer.getPort()); + return new ApiConnectionInfoBuilder(baseUrl).build(); + } + + ApiConnectionInfo buildAuthInfo() { + String baseUrl = String.format("http://localhost:%s", mockAuthServer.getPort()); + return new ApiConnectionInfoBuilder(baseUrl).build(); + } + + protected String getResource(String resource) throws IOException { + URL resourceUrl = getClass().getClassLoader().getResource(resource); + if (resourceUrl == null) { + throw new IOException("Failed to get resource: " + resource); + } + Path path = new File(resourceUrl.getFile()).toPath(); + return String.join("\n", Files.readAllLines(path)); + } + + @Test + void testBuildTokenProvider() throws Exception { + mockAuthServer.getMockServer().setDispatcher(new Dispatcher() { + private final Logger LOGGER = Logger.getLogger(TestOidcTokenProvider.class.getName()+"_dispatcher"); + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + final HttpUrl url = request.getRequestUrl(); + final String path = url.encodedPath(); + LOGGER.info("Request for: " + url.toString()); + LOGGER.fine("Path: " + path); + + try { + if (path.endsWith("openid-configuration")) { + return new MockResponse().setBody(getResource("openIdConfig2.json") + .replace("PORT", ""+mockAuthServer.getPort())); + } + else if (path.endsWith("/auth")) { + final String query = request.getRequestUrl().query(); + final QueryParameters parameters = QueryParameters.parse(query); + String redirect = parameters.get("redirect_uri").get(0); + String state = parameters.get("state").get(0); + final String loc = String.format("%s?code=test&state=%s&session_state=zzz", redirect, state); + MockResponse response = new MockResponse().setResponseCode(302).setHeader("Location", loc).setBody("hello"); + LOGGER.info(response.toString()); + return response; + } + else if (path.endsWith("/token")) { + return new MockResponse().setBody(getResource("oauth2token.json")); + } + } catch (IOException ex) { + fail("Couldn't process mocked request", ex); + } + return new MockResponse().setResponseCode(404).setBody("Request not mocked."); + } + }); + + + String wellKnown = "http://localhost:"+mockAuthServer.getPort()+"/auth/realms/cwbi/.well-known/openid-configuration"; + OidcAuthTokenProvider tokenProvider = new OidcAuthTokenProvider("test", wellKnown); + tokenProvider.setAuthCallback(u -> { + try { + LOGGER.info("Sending " + u.toString()); + HttpRequestExecutor executor = + new HttpRequestBuilderImpl(new ApiConnectionInfoBuilder(u.toString()).build()) + .get() + .withMediaType("text/plain"); + + try (HttpRequestResponse response = executor.execute()) { + // redirect should be automatically followed. If changes + // made that fail this section either enable redirect or handle it. + } + + } catch (IOException ex) { + fail("Unable to perform client side of authorization procedure.", ex); + } + + }); + final OAuth2Token token = tokenProvider.getToken(); + assertNotNull(token); + } +} diff --git a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOpenIdTokenController.java b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOpenIdTokenController.java index 3abbd67c..b0f46296 100644 --- a/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOpenIdTokenController.java +++ b/cwbi-auth-http-client/src/test/java/hec/army/usace/hec/cwbi/auth/http/client/TestOpenIdTokenController.java @@ -51,7 +51,7 @@ void testRetrieveTokenUrl() throws Exception { ApiConnectionInfo tokenUrl = new OpenIdTokenController(){ @Override - protected String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) { + public String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) { return wellKnownEndpoint; } }.retrieveTokenUrl(new ApiConnectionInfoBuilder(baseUrl).build(), sslSocketData); diff --git a/cwbi-auth-http-client/src/test/resources/openIdConfig2.json b/cwbi-auth-http-client/src/test/resources/openIdConfig2.json new file mode 100644 index 00000000..73690146 --- /dev/null +++ b/cwbi-auth-http-client/src/test/resources/openIdConfig2.json @@ -0,0 +1,121 @@ +{ + "issuer": "http://localhost:PORT/auth/realms/cwbi", + "authorization_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/token", + "introspection_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/token/introspect", + "userinfo_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/userinfo", + "end_session_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/logout", + "frontchannel_logout_session_supported": true, + "frontchannel_logout_supported": true, + "jwks_uri": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/certs", + "check_session_iframe": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/login-status-iframe.html", + "grant_types_supported": [ + "authorization_code", "implicit", "refresh_token", "password", "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code", "urn:openid:params:grant-type:ciba" + ], + "acr_values_supported": ["0", "1"], + "response_types_supported": [ + "code", "none", "id_token", "token", "id_token token", + "code id_token", "code token", "code id_token token" + ], + "subject_types_supported": ["public", "pairwise"], + "id_token_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512" + ], + "id_token_encryption_alg_values_supported": ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "id_token_encryption_enc_values_supported": [ + "A256GCM", "A192GCM", "A128GCM", "A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512" + ], + "userinfo_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512", "none" + ], + "userinfo_encryption_alg_values_supported": ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "userinfo_encryption_enc_values_supported": [ + "A256GCM", "A192GCM", "A128GCM", "A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512" + ], + "request_object_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512", "none" + ], + "request_object_encryption_alg_values_supported": ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "request_object_encryption_enc_values_supported": [ + "A256GCM", "A192GCM", "A128GCM", "A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512" + ], + "response_modes_supported": [ + "query", "fragment", "form_post", "query.jwt", "fragment.jwt", "form_post.jwt", "jwt" + ], + "registration_endpoint": "http://localhost:PORT/auth/realms/cwbi/clients-registrations/openid-connect", + "token_endpoint_auth_methods_supported": [ + "private_key_jwt", "client_secret_basic", "client_secret_post", + "tls_client_auth", "client_secret_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512" + ], + "introspection_endpoint_auth_methods_supported": [ + "private_key_jwt", "client_secret_basic", "client_secret_post", + "tls_client_auth", "client_secret_jwt" + ], + "introspection_endpoint_auth_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512" + ], + "authorization_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512" + ], + "authorization_encryption_alg_values_supported": ["RSA-OAEP", "RSA-OAEP-256", "RSA1_5"], + "authorization_encryption_enc_values_supported": [ + "A256GCM", "A192GCM", "A128GCM", "A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512" + ], + "claims_supported": [ + "aud", "sub", "iss", "auth_time", "name", "given_name", "family_name", + "preferred_username", "email", "acr" + ], + "claim_types_supported": ["normal"], + "claims_parameter_supported": true, + "scopes_supported": [ + "openid", "stig-manager:collection:read", "stig-manager:op", "x509_presented", + "stig-manager:op:read", "email", "stig-manager:collection", "microprofile-jwt", + "address", "acr", "profile", "groups", "stig-manager:stig", "roles", + "web-origins", "stig-manager:stig:read", "offline_access", "cacUID", + "preferred_username", "stig-manager:user:read", "phone", "subjectDN", "stig-manager:user" + ], + "request_parameter_supported": true, + "request_uri_parameter_supported": true, + "require_request_uri_registration": true, + "code_challenge_methods_supported": ["plain", "S256"], + "tls_client_certificate_bound_access_tokens": true, + "revocation_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/revoke", + "revocation_endpoint_auth_methods_supported": [ + "private_key_jwt", "client_secret_basic", "client_secret_post", + "tls_client_auth", "client_secret_jwt" + ], + "revocation_endpoint_auth_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "HS256", "HS512", "ES256", "RS256", + "HS384", "ES512", "PS256", "PS512", "RS512" + ], + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "device_authorization_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/auth/device", + "backchannel_token_delivery_modes_supported": ["poll", "ping"], + "backchannel_authentication_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/ext/ciba/auth", + "backchannel_authentication_request_signing_alg_values_supported": [ + "PS384", "ES384", "RS384", "ES256", "RS256", "ES512", "PS256", "PS512", "RS512" + ], + "require_pushed_authorization_requests": false, + "pushed_authorization_request_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/ext/par/request", + "mtls_endpoint_aliases": { + "token_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/token", + "revocation_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/revoke", + "introspection_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/token/introspect", + "device_authorization_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/auth/device", + "registration_endpoint": "http://localhost:PORT/auth/realms/cwbi/clients-registrations/openid-connect", + "userinfo_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/userinfo", + "pushed_authorization_request_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/ext/par/request", + "backchannel_authentication_endpoint": "http://localhost:PORT/auth/realms/cwbi/protocol/openid-connect/ext/ciba/auth" + } +} diff --git a/cwms-aaa-client/build.gradle b/cwms-aaa-client/build.gradle index 352ba583..23d66ac1 100644 --- a/cwms-aaa-client/build.gradle +++ b/cwms-aaa-client/build.gradle @@ -11,6 +11,7 @@ dependencies { testImplementation(testFixtures(project(":cwms-http-client"))) testImplementation(libs.junit.api) testRuntimeOnly(libs.junit.engine) + testImplementation(libs.okhttp.mockwebserver) } publishing { diff --git a/cwms-data-api-client/build.gradle b/cwms-data-api-client/build.gradle index b76a59d7..0badad95 100644 --- a/cwms-data-api-client/build.gradle +++ b/cwms-data-api-client/build.gradle @@ -40,6 +40,7 @@ dependencies { testImplementation(libs.mockito.core) testImplementation(libs.mockito.junit.jupiter) testImplementation(libs.jackson.yaml) + testImplementation(libs.okhttp.mockwebserver) } diff --git a/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/auth/CdaTokenProviderFactory.java b/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/auth/CdaTokenProviderFactory.java deleted file mode 100644 index 1a5d4d9f..00000000 --- a/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/auth/CdaTokenProviderFactory.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package mil.army.usace.hec.cwms.data.api.client.auth; - -import hec.army.usace.hec.cwbi.auth.http.client.CwbiAuthSslSocketFactory; -import hec.army.usace.hec.cwbi.auth.http.client.DiscoveredCwbiAuthTokenProvider; -import hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager; -import java.io.IOException; -import java.util.Collections; -import java.util.Objects; -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLSocketFactory; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder; -import mil.army.usace.hec.cwms.http.client.SslSocketData; -import mil.army.usace.hec.cwms.http.client.auth.OAuth2TokenProvider; - -public final class CdaTokenProviderFactory { - - private CdaTokenProviderFactory() { - throw new AssertionError("This class should not be instantiated"); - } - - public static OAuth2TokenProvider createTokenProvider(String url, String clientId, KeyManager keyManager) throws IOException { - SSLSocketFactory sslSocketFactory = CwbiAuthSslSocketFactory.buildSSLSocketFactory(Collections.singletonList(Objects.requireNonNull(keyManager, "Missing required KeyManager"))); - SslSocketData sslSocketData = new SslSocketData(Objects.requireNonNull(sslSocketFactory, "Missing required SSLSocketFactory"), - CwbiAuthTrustManager.getTrustManager()); - ApiConnectionInfo apiConnectionInfo = new ApiConnectionInfoBuilder(Objects.requireNonNull(url, "Missing required url")).build(); - CdaTokenUrlDiscoveryService tokenUrlDiscoveryService = new CdaTokenUrlDiscoveryService(apiConnectionInfo, sslSocketData); - return new DiscoveredCwbiAuthTokenProvider(Objects.requireNonNull(clientId, "Missing required clientId"), tokenUrlDiscoveryService); - } -} diff --git a/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/auth/CdaTokenUrlDiscoveryService.java b/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/auth/CdaTokenUrlDiscoveryService.java deleted file mode 100644 index d5e23226..00000000 --- a/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/auth/CdaTokenUrlDiscoveryService.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package mil.army.usace.hec.cwms.data.api.client.auth; - -import hec.army.usace.hec.cwbi.auth.http.client.TokenUrlDiscoveryService; -import java.io.IOException; -import java.util.Objects; -import mil.army.usace.hec.cwms.data.api.client.controllers.CdaOpenIdTokenController; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -import mil.army.usace.hec.cwms.http.client.SslSocketData; - -final class CdaTokenUrlDiscoveryService implements TokenUrlDiscoveryService { - - private final ApiConnectionInfo apiConnectionInfo; - private final SslSocketData sslSocketData; - - CdaTokenUrlDiscoveryService(ApiConnectionInfo apiConnectionInfo, SslSocketData sslSocketData) { - this.apiConnectionInfo = Objects.requireNonNull(apiConnectionInfo, "apiConnectionInfo is required"); - this.sslSocketData = Objects.requireNonNull(sslSocketData, "sslSocketData is required"); - } - @Override - public ApiConnectionInfo discoverTokenUrl() throws IOException { - return new CdaOpenIdTokenController() - .retrieveTokenUrl(apiConnectionInfo, sslSocketData); - } -} diff --git a/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/controllers/CdaOpenIdTokenController.java b/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/controllers/CdaOpenIdTokenController.java index 8fe7770c..97b2e38b 100644 --- a/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/controllers/CdaOpenIdTokenController.java +++ b/cwms-data-api-client/src/main/java/mil/army/usace/hec/cwms/data/api/client/controllers/CdaOpenIdTokenController.java @@ -34,7 +34,7 @@ public final class CdaOpenIdTokenController extends OpenIdTokenController { private static final String SWAGGER_DOC_ENDPOINT = "swagger-docs"; @Override - protected String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) throws IOException { + public String retrieveWellKnownEndpoint(ApiConnectionInfo apiConnectionInfo) throws IOException { String url = apiConnectionInfo.getApiRoot() + "/" + SWAGGER_DOC_ENDPOINT; OpenAPI openAPI = new OpenAPIV3Parser().read(url); if(openAPI == null) { diff --git a/cwms-data-api-client/src/test/java/mil/army/usace/hec/cwms/data/api/client/auth/TestCdaTokenProviderFactory.java b/cwms-data-api-client/src/test/java/mil/army/usace/hec/cwms/data/api/client/auth/TestCdaTokenProviderFactory.java deleted file mode 100644 index 9ef4cd7e..00000000 --- a/cwms-data-api-client/src/test/java/mil/army/usace/hec/cwms/data/api/client/auth/TestCdaTokenProviderFactory.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package mil.army.usace.hec.cwms.data.api.client.auth; - -import java.io.IOException; -import javax.net.ssl.KeyManager; -import mil.army.usace.hec.cwms.data.api.client.controllers.TestController; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -import mil.army.usace.hec.cwms.http.client.auth.OAuth2TokenProvider; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import org.junit.jupiter.api.Test; - -final class TestCdaTokenProviderFactory extends TestController { - - @Test - void testNotNull() throws IOException { - String resource = "radar/v1/json/openIdConfig.json"; - String collect = readJsonFile(resource); - mockHttpServer.enqueue(collect); - ApiConnectionInfo webServiceUrl = buildConnectionInfo(); - OAuth2TokenProvider tokenProvider = CdaTokenProviderFactory.createTokenProvider(webServiceUrl.getApiRoot(),"cwms", new KeyManager() {}); - assertNotNull(tokenProvider); - } - - @Test - void testNulls() { - assertThrows(NullPointerException.class, () -> CdaTokenProviderFactory.createTokenProvider("test", "cwms", null)); - assertThrows(NullPointerException.class, () -> CdaTokenProviderFactory.createTokenProvider("test", null, new KeyManager() {})); - assertThrows(NullPointerException.class, () -> CdaTokenProviderFactory.createTokenProvider(null, "cwms", new KeyManager() {})); - } -} diff --git a/cwms-data-api-client/src/test/java/mil/army/usace/hec/cwms/data/api/client/auth/TestCdaTokenUrlDiscoveryService.java b/cwms-data-api-client/src/test/java/mil/army/usace/hec/cwms/data/api/client/auth/TestCdaTokenUrlDiscoveryService.java deleted file mode 100644 index 35600d82..00000000 --- a/cwms-data-api-client/src/test/java/mil/army/usace/hec/cwms/data/api/client/auth/TestCdaTokenUrlDiscoveryService.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2025 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package mil.army.usace.hec.cwms.data.api.client.auth; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager; -import java.io.IOException; -import javax.net.ssl.SSLSocketFactory; -import mil.army.usace.hec.cwms.data.api.client.controllers.TestController; -import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; -import mil.army.usace.hec.cwms.http.client.SslSocketData; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -final class TestCdaTokenUrlDiscoveryService extends TestController { - - @Test - void testCdaTokenUrlDiscoveryService() throws IOException { - SSLSocketFactory mockSslSocketFactory = Mockito.mock(SSLSocketFactory.class); - String resource = readSwaggerYamlAsJson(); - String openIdConfig = "radar/v1/json/openIdConfig.json"; - ObjectMapper mapper = new ObjectMapper(); - ObjectNode node = (ObjectNode) mapper.readTree(resource); - ApiConnectionInfo webServiceUrl = buildConnectionInfo(); - ObjectNode components = node.with("components"); - ObjectNode securitySchemes = components.with("securitySchemes"); - ObjectNode openIdConnect = securitySchemes.with("OpenIDConnect"); - openIdConnect.put("openIdConnectUrl", webServiceUrl.getApiRoot() + "/.well-known/openid-configuration"); - String updatedIdpConfig = mapper.writeValueAsString(node); - mockHttpServer.enqueue(updatedIdpConfig); - mockHttpServer.enqueue(readJsonFile(openIdConfig)); - SslSocketData sslSocketData = new SslSocketData(mockSslSocketFactory, CwbiAuthTrustManager.getTrustManager()); - CdaTokenUrlDiscoveryService discoveryService = new CdaTokenUrlDiscoveryService(webServiceUrl, sslSocketData); - assertEquals("https://api.example.com/auth/realms/cwbi/protocol/openid-connect/token", discoveryService.discoverTokenUrl().getApiRoot()); - } - - @Test - void testNulls() { - SSLSocketFactory mockSslSocketFactory = Mockito.mock(SSLSocketFactory.class); - SslSocketData sslSocketData = new SslSocketData(mockSslSocketFactory, CwbiAuthTrustManager.getTrustManager()); - ApiConnectionInfo webServiceUrl = buildConnectionInfo(); - assertThrows(NullPointerException.class, () -> new CdaTokenUrlDiscoveryService(null, sslSocketData)); - assertThrows(NullPointerException.class, () -> new CdaTokenUrlDiscoveryService(webServiceUrl, null)); - } -} diff --git a/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/SslSocketData.java b/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/SslSocketData.java index 46b96cf7..9450679e 100644 --- a/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/SslSocketData.java +++ b/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/SslSocketData.java @@ -38,11 +38,11 @@ public SslSocketData(SSLSocketFactory sslSocketFactory, X509TrustManager x509Tru this.x509TrustManager = Objects.requireNonNull(x509TrustManager, "Missing required X509TrustManager"); } - SSLSocketFactory getSslSocketFactory() { + public SSLSocketFactory getSslSocketFactory() { return sslSocketFactory; } - X509TrustManager getX509TrustManager() { + public X509TrustManager getX509TrustManager() { return x509TrustManager; } } diff --git a/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/auth/OAuth2TokenProvider.java b/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/auth/OAuth2TokenProvider.java index adb6d66c..c709c6bb 100644 --- a/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/auth/OAuth2TokenProvider.java +++ b/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/auth/OAuth2TokenProvider.java @@ -25,7 +25,16 @@ package mil.army.usace.hec.cwms.http.client.auth; import java.io.IOException; +import java.net.URI; +import java.util.function.Consumer; +import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo; + +/** + * + * TODO: needs additional support for alternative flows. deciding if attempting to + * do in this PR or just create issue and do in followup PR. + */ public interface OAuth2TokenProvider { void clear(); @@ -36,4 +45,19 @@ public interface OAuth2TokenProvider { OAuth2Token newToken() throws IOException; + /** + * Return auth callback that will be used for this provider. + * By default do nothing. + * @return + */ + default Consumer getAuthCallback() { + return u -> {}; + } + + default void setAuthCallback(Consumer authCallback) { + /** default do nothing... for now */ + } + + ApiConnectionInfo getAuthUrl(); + ApiConnectionInfo getTokenUrl(); } diff --git a/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/request/QueryParameters.java b/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/request/QueryParameters.java new file mode 100644 index 00000000..7f61402f --- /dev/null +++ b/cwms-http-client/src/main/java/mil/army/usace/hec/cwms/http/client/request/QueryParameters.java @@ -0,0 +1,104 @@ +package mil.army.usace.hec.cwms.http.client.request; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Helper class to support creation and query of URL query parameters + */ +public class QueryParameters { + + private Map> parameters = new HashMap<>(); + + + + private QueryParameters() { + + } + + /** + * Retrieve Parameter list + * @param parameter + * @return List of parameters. If no values are set for the parameter an empty list is returned. + */ + public List get(String parameter) { + List values = parameters.computeIfAbsent(parameter, p -> new ArrayList<>()); + return values; + } + + /** + * Add an additional value to the parameter + * @param parameter + * @param value + * @return this instance to allow for fluent syntax operations + */ + public QueryParameters add(String parameter, String value) { + List values = parameters.computeIfAbsent(parameter, p -> new ArrayList<>()); + values.add(value); + return this; + } + + /** + * Set given parameter to only the given value. Any previously stored values are lost. + * @param parameter + * @param value + * @return this instance to allow for fluent syntax operations + */ + public QueryParameters set(String parameter, String value) { + List values = new ArrayList<>(); + values.add(value); + parameters.put(parameter, values); + return this; + } + + /** + * URL encoded query parameters suitable for appending to query. Does not include the initial ? + * @return + */ + public String encode() { + final ArrayList sets = new ArrayList<>(); + parameters.forEach((k,v) -> { + ArrayList pairs = new ArrayList<>(); + for (String value: v) { + pairs.add(String.format("%s=%s", + URLEncoder.encode(k, StandardCharsets.UTF_8), + URLEncoder.encode(value, StandardCharsets.UTF_8))); + } + sets.add(String.join("&", pairs)); + }); + + return String.join("&", sets); + } + + /** + * Given a URL query string, create a QueryParameters object for additional processing. + * @param query + * @return + */ + public static QueryParameters parse(String query) { + QueryParameters parameters = new QueryParameters(); + for (String pair: query.split("&")) { + String[] kv = pair.split("=", 2); // parameters are *always* seperated by &, but may have embedded = + if (kv[0].trim().isEmpty()) { + continue; + } + String parameter = URLDecoder.decode(kv[0], StandardCharsets.UTF_8); + String value = kv.length > 1 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : null; + parameters.parameters.computeIfAbsent(parameter, p -> new ArrayList<>()).add(value); + } + return parameters; + } + + /** + * Create a new QueryParameters object. + * @return + */ + public static QueryParameters empty() { + return parse(""); + } +} diff --git a/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestApiConnectionInfo.java b/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestApiConnectionInfo.java index ec4b0849..2bfbb95c 100644 --- a/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestApiConnectionInfo.java +++ b/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestApiConnectionInfo.java @@ -233,6 +233,18 @@ public OAuth2Token newToken() { token.setScope("create"); return token; } + + @Override + public ApiConnectionInfo getAuthUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getAuthUrl'"); + } + + @Override + public ApiConnectionInfo getTokenUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getTokenUrl'"); + } }; } @@ -278,6 +290,18 @@ public OAuth2Token refreshToken() { public OAuth2Token newToken() { return null; } + + @Override + public ApiConnectionInfo getAuthUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getAuthUrl'"); + } + + @Override + public ApiConnectionInfo getTokenUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getTokenUrl'"); + } }; } diff --git a/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenAuthenticator.java b/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenAuthenticator.java index 50bc4028..28f07622 100644 --- a/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenAuthenticator.java +++ b/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenAuthenticator.java @@ -261,5 +261,17 @@ public OAuth2Token newToken() { token.setRefreshToken(ACCESS_TOKEN); return token; } + + @Override + public ApiConnectionInfo getAuthUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getAuthUrl'"); + } + + @Override + public ApiConnectionInfo getTokenUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getTokenUrl'"); + } } } diff --git a/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenInterceptor.java b/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenInterceptor.java index 7675fa81..533a3a7d 100644 --- a/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenInterceptor.java +++ b/cwms-http-client/src/test/java/mil/army/usace/hec/cwms/http/client/TestOAuth2TokenInterceptor.java @@ -104,6 +104,18 @@ public OAuth2Token newToken() { return token; } + @Override + public ApiConnectionInfo getAuthUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getAuthUrl'"); + } + + @Override + public ApiConnectionInfo getTokenUrl() { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getTokenUrl'"); + } + }; } } diff --git a/cwms-http-client/src/testFixtures/java/mil/army/usace/hec/cwms/http/client/MockHttpServer.java b/cwms-http-client/src/testFixtures/java/mil/army/usace/hec/cwms/http/client/MockHttpServer.java index 6e6cfc07..463b568f 100644 --- a/cwms-http-client/src/testFixtures/java/mil/army/usace/hec/cwms/http/client/MockHttpServer.java +++ b/cwms-http-client/src/testFixtures/java/mil/army/usace/hec/cwms/http/client/MockHttpServer.java @@ -52,6 +52,10 @@ public void enqueue(int responseCode, String body) { mockWebServer.enqueue(new MockResponse().setResponseCode(responseCode).setBody(body)); } + public void enqueue(MockResponse response) { + mockWebServer.enqueue(response); + } + public void enqueue(String body, List cookies) { MockResponse mockResponse = new MockResponse().setBody(body); for (String cookie : cookies) { @@ -85,6 +89,10 @@ public void close() throws Exception { mockWebServer.close(); } + public MockWebServer getMockServer() { + return mockWebServer; + } + public RequestWrapper takeRequest() throws Exception { return new RequestWrapper(mockWebServer.takeRequest()); } diff --git a/hec-build-ext.gradle b/hec-build-ext.gradle new file mode 100644 index 00000000..af15166d --- /dev/null +++ b/hec-build-ext.gradle @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2024 + * United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC) + * All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL. + * Source may not be released without written approval from HEC + */ + +includeBuild("$gradle.ext.externalLibDir") { + dependencySubstitution { + substitute(module("mil.army.usace.hec:cwms-http-client")).using(project(":cwms-http-client")) + substitute(module("mil.army.usace.hec:cwms-data-api-client")).using(project(":cwms-data-api-client")) + substitute(module("mil.army.usace.hec:cwms-data-api-model")).using(project(":cwms-data-api-model")) + substitute(module("mil.army.usace.hec:cwms-aaa-client")).using(project(":cwms-aaa-client")) + substitute(module("mil.army.usace.hec:cwbi-auth-http-client")).using(project(":cwbi-auth-http-client")) + + } +}