Skip to content

Commit 0b81c12

Browse files
MikeNeilsonbuilduser
andauthored
Feature/shar 285 OIDC pkce (#280)
* WIP, need to request use of dispatcher vs enqueue in test. * Initial implementation of a more 'pure' OpenID Connection token provider. * remove identityc substitution. * Correct missing semicolons. * Update jacoco version. * Allow caller to determine auth callback methods. * Autogenerated JaCoCo coverage badge * Correct code generation and apply timeout to login sequence. * CwbiAUthTokenProviderBase now extends OidcAuthTokenProvider. Removed previous DiscoveryProvider as all configuration discovery is now handled within OidcAuthTokenProvider. * Override URL and SSL data for DirectGrant only. * Correct tests for new behavior. * Autogenerated JaCoCo coverage badge * PR review updates. --------- Co-authored-by: builduser <builduser@rmanet.com>
1 parent 81f770e commit 0b81c12

40 files changed

Lines changed: 1115 additions & 653 deletions

File tree

.github/coveragereport/badge_branchcoverage.svg

Lines changed: 1 addition & 1 deletion
Loading

.github/coveragereport/badge_linecoverage.svg

Lines changed: 1 addition & 1 deletion
Loading

.github/coveragereport/badge_methodcoverage.svg

Lines changed: 1 addition & 1 deletion
Loading

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ build
88
**/bin
99
**/.idea/
1010
lib/
11+
.vscode

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ subprojects {
3232
}
3333

3434
jacoco {
35-
toolVersion = "0.8.7"
35+
toolVersion = "0.8.11"
3636
}
3737

3838
jacocoTestReport {

buildSrc/src/main/groovy/cwms-data-api-client.java-conventions.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ configurations.implementation.extendsFrom(configurations.annotationProcessor)
2222

2323
test {
2424
useJUnitPlatform()
25+
// when passing --tests by default it needs to match all in all subjects
26+
filter {
27+
setFailOnNoMatchingTests(false)
28+
}
2529
}
2630

2731
javadoc {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Hydrologic Engineering Center
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
package hec.army.usace.hec.cwbi.auth.http.client;
25+
26+
import mil.army.usace.hec.cwms.http.client.HttpRequestBuilderImpl;
27+
import mil.army.usace.hec.cwms.http.client.HttpRequestResponse;
28+
import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token;
29+
import mil.army.usace.hec.cwms.http.client.request.HttpRequestExecutor;
30+
import mil.army.usace.hec.cwms.http.client.request.QueryParameters;
31+
32+
import java.io.IOException;
33+
import java.net.InetSocketAddress;
34+
import java.net.URI;
35+
import java.nio.charset.StandardCharsets;
36+
import java.security.MessageDigest;
37+
import java.security.NoSuchAlgorithmException;
38+
import java.security.SecureRandom;
39+
import java.util.Base64;
40+
import java.util.UUID;
41+
import java.util.concurrent.CompletableFuture;
42+
import java.util.concurrent.ExecutionException;
43+
import java.util.concurrent.TimeUnit;
44+
import java.util.concurrent.TimeoutException;
45+
import java.util.logging.Logger;
46+
47+
import com.sun.net.httpserver.HttpExchange;
48+
import com.sun.net.httpserver.HttpHandler;
49+
import com.sun.net.httpserver.HttpServer;
50+
51+
/**
52+
* Use Authorization Code + PKCE method to retrieve initial token set.
53+
*
54+
* If a desktop is available users's default Browser is opened with the given auth URL
55+
* To complete the additional requirements.
56+
*/
57+
public final class AuthCodePkceTokenRequestBuilder extends TokenRequestBuilder<AuthCodePkceTokenRequestBuilder> {
58+
private static final Logger LOGGER = Logger.getLogger(AuthCodePkceTokenRequestBuilder.class.getName());
59+
@Override
60+
OAuth2Token retrieveToken() throws IOException {
61+
62+
OAuth2Token retVal = null;
63+
HttpServer server = null;
64+
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
65+
try {
66+
byte[] verifierBytes = new byte[128];
67+
SecureRandom.getInstanceStrong().nextBytes(verifierBytes);
68+
Base64.Encoder b64encoder = Base64.getUrlEncoder().withoutPadding();
69+
final String verifier = b64encoder.encodeToString(verifierBytes);
70+
final String originalState = UUID.randomUUID().toString();
71+
72+
MessageDigest md = MessageDigest.getInstance("SHA-256");
73+
final String challenge = b64encoder.encodeToString(md.digest(verifier.getBytes(StandardCharsets.US_ASCII)));
74+
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
75+
int port = server.getAddress().getPort();
76+
String host = server.getAddress().getHostName();
77+
78+
final CompletableFuture<Result> future = new CompletableFuture<>();
79+
80+
server.createContext("/", new HttpHandler() {
81+
82+
@Override
83+
public void handle(HttpExchange exchange) throws IOException {
84+
Result ret = null;
85+
86+
final String query = exchange.getRequestURI().getQuery();
87+
LOGGER.fine("Got auth server response." + query);
88+
final QueryParameters parameters = QueryParameters.parse(query);
89+
if (!parameters.get("error").isEmpty()) {
90+
String error = parameters.get("error").get(0);
91+
String errorDescription = parameters.get("error_description").get(0);
92+
ret = Result.failure(error, errorDescription);
93+
} else {
94+
String code = parameters.get("code").get(0);
95+
String state = parameters.get("state").get(0);
96+
String session_state = parameters.get("session_state").get(0);
97+
ret = Result.success(code ,state, session_state);
98+
}
99+
LOGGER.fine("Returning result back to thread.");
100+
exchange.sendResponseHeaders(204, 0);
101+
102+
future.complete(ret);
103+
}
104+
105+
});
106+
final String redirectUri = String.format("http://%s:%d", host, port);
107+
final QueryParameters authParameters = QueryParameters.empty()
108+
.set("grant_type", "code")
109+
.set("client_id", getClientId())
110+
.set("scope", "openid profile")
111+
.set("response_type", "code")
112+
.set("code_challenge_method", "S256")
113+
.set("code_challenge", challenge)
114+
.set("redirect_uri", redirectUri)
115+
.set("state", originalState);
116+
String urlStr= String.format("%s?%s", getAuthUrl().getApiRoot(), authParameters.encode());
117+
// start server to listen
118+
server.start();
119+
LOGGER.info("Handling Auth Request");
120+
LOGGER.finer("Auth Request URL: " + urlStr);
121+
this.authCallBack.accept(URI.create(urlStr));
122+
123+
Result result = future.get(3, TimeUnit.MINUTES); // The user is now required to perform manual operations.
124+
LOGGER.info("Retrieving Token.");
125+
if (result.error != null) {
126+
throw new IOException(String.format("Unable to login. %s : %s", result.error, result.errorDescription));
127+
}
128+
if (!result.state.equals(originalState)) {
129+
throw new IOException("Unable to continue login sequence, incorrect state value returned.");
130+
}
131+
final UrlEncodedFormData formData = new UrlEncodedFormData();
132+
formData.addClientId(getClientId())
133+
.addGrantType("authorization_code")
134+
.addParameter("code_verifier", verifier)
135+
.addScopes("openid", "profile")
136+
.addParameter("redirect_uri", redirectUri)
137+
.addParameter("state", result.state)
138+
.addParameter("session_state", result.session_state)
139+
.addParameter("code", result.code)
140+
.addParameter("response_mode", "fragment")
141+
.addParameter("response_type", "id_token token");
142+
143+
HttpRequestExecutor executor =
144+
new HttpRequestBuilderImpl(getTokenUrl())
145+
.post()
146+
.withBody(formData.buildEncodedString())
147+
.withMediaType(MEDIA_TYPE);
148+
try (HttpRequestResponse response = executor.execute()) {
149+
String body = response.getBody();
150+
if (body != null) {
151+
retVal = OAuth2ObjectMapper.mapJsonToObject(body, OAuth2Token.class);
152+
}
153+
}
154+
return retVal;
155+
} catch (NoSuchAlgorithmException ex) {
156+
throw new IOException("Unable to retrieve SecureRandom or Message Digest instance to generate verifier", ex);
157+
} catch (InterruptedException | ExecutionException | TimeoutException ex) {
158+
throw new IOException("Unable to form login sequence.", ex);
159+
}
160+
finally {
161+
if (server != null) {
162+
server.stop(0);
163+
}
164+
}
165+
}
166+
167+
private static class Result {
168+
public final String code;
169+
public final String state;
170+
public final String session_state;
171+
172+
public final String error;
173+
public final String errorDescription;
174+
175+
private Result(String code, String state, String session_state, String error, String errorDescription) {
176+
this.code = code;
177+
this.state = state;
178+
this.session_state = session_state;
179+
this.error = error;
180+
this.errorDescription = errorDescription;
181+
}
182+
183+
public static Result success(String code, String state, String session_state) {
184+
return new Result(code, state, session_state, null, null);
185+
}
186+
187+
public static Result failure(String error, String errorDescription) {
188+
return new Result(null, null, null, error, errorDescription);
189+
}
190+
};
191+
}

cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProvider.java

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,35 +24,55 @@
2424
package hec.army.usace.hec.cwbi.auth.http.client;
2525

2626
import hec.army.usace.hec.cwbi.auth.http.client.trustmanagers.CwbiAuthTrustManager;
27+
28+
import java.io.IOException;
2729
import java.util.Objects;
30+
2831
import javax.net.ssl.SSLSocketFactory;
2932
import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo;
3033
import mil.army.usace.hec.cwms.http.client.ApiConnectionInfoBuilder;
3134
import mil.army.usace.hec.cwms.http.client.SslSocketData;
35+
import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token;
3236

37+
/**
38+
* Suitable only for CWBI Keycloaks direct grant setup.
39+
*/
3340
public final class CwbiAuthTokenProvider extends CwbiAuthTokenProviderBase {
3441

3542
private final SSLSocketFactory sslSocketFactory;
36-
private final String url;
3743

3844
/**
3945
* Provider for OAuth2Tokens.
4046
*
41-
* @param tokenUrl - URL we are fetching token from
47+
* @param wellKnownUrl - URL we are retrieving configuration from
4248
* @param clientId - client name
4349
* @param sslSocketFactory - ssl socket factory
4450
*/
45-
public CwbiAuthTokenProvider(String tokenUrl, String clientId, SSLSocketFactory sslSocketFactory) {
46-
super(clientId);
51+
public CwbiAuthTokenProvider(String wellKnownUrl, String clientId, SSLSocketFactory sslSocketFactory) {
52+
super(clientId, wellKnownUrl);
4753
this.sslSocketFactory = Objects.requireNonNull(sslSocketFactory, "Missing required sslSocketFactory");
48-
this.url = Objects.requireNonNull(tokenUrl, "Missing required tokenUrl");
4954
}
5055

5156
@Override
5257
ApiConnectionInfo getUrl() {
53-
return new ApiConnectionInfoBuilder(url)
58+
return new ApiConnectionInfoBuilder(this.wellKnownUrl)
59+
.withSslSocketData(new SslSocketData(sslSocketFactory, CwbiAuthTrustManager.getTrustManager()))
60+
.build();
61+
}
62+
63+
@Override
64+
public ApiConnectionInfo getAuthUrl() {
65+
// This is specific to CWBI Direct Grant so this replacement as-is is fine
66+
return new ApiConnectionInfoBuilder(this.tokenUrl.getApiRoot().replace("identity", "identityc"))
5467
.withSslSocketData(new SslSocketData(sslSocketFactory, CwbiAuthTrustManager.getTrustManager()))
5568
.build();
5669
}
5770

71+
@Override
72+
public OAuth2Token newToken() throws IOException {
73+
return new DirectGrantX509TokenRequestBuilder()
74+
.withTokenUrl(getAuthUrl())
75+
.buildRequest().withClientId(clientId)
76+
.fetchToken();
77+
}
5878
}

cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/CwbiAuthTokenProviderBase.java

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,50 +24,35 @@
2424
package hec.army.usace.hec.cwbi.auth.http.client;
2525

2626
import java.io.IOException;
27-
import java.util.Objects;
27+
2828
import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo;
2929
import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token;
30-
import mil.army.usace.hec.cwms.http.client.auth.OAuth2TokenProvider;
3130

32-
abstract class CwbiAuthTokenProviderBase implements OAuth2TokenProvider {
33-
protected OAuth2Token oauth2Token;
34-
protected final String clientId;
31+
abstract class CwbiAuthTokenProviderBase extends OidcAuthTokenProvider {
3532

36-
protected CwbiAuthTokenProviderBase(String clientId) {
37-
this.clientId = Objects.requireNonNull(clientId, "Missing required clientId");
33+
protected CwbiAuthTokenProviderBase(String clientId, String wellKnownUrl) {
34+
super(clientId, wellKnownUrl);
3835
}
3936

4037
abstract ApiConnectionInfo getUrl() throws IOException;
4138

42-
@Override
43-
public synchronized void clear() {
44-
oauth2Token = null;
45-
}
4639

4740
@Override
4841
public synchronized OAuth2Token getToken() throws IOException {
49-
if (oauth2Token == null) {
50-
oauth2Token = newToken();
42+
if (token == null) {
43+
token = newToken();
5144
}
52-
return oauth2Token;
53-
}
54-
55-
@Override
56-
public OAuth2Token newToken() throws IOException {
57-
return new DirectGrantX509TokenRequestBuilder()
58-
.withUrl(getUrl())
59-
.withClientId(clientId)
60-
.fetchToken();
45+
return token;
6146
}
6247

6348
@Override
6449
public synchronized OAuth2Token refreshToken() throws IOException {
65-
OAuth2Token token = new RefreshTokenRequestBuilder()
66-
.withRefreshToken(oauth2Token.getRefreshToken())
67-
.withUrl(getUrl())
50+
OAuth2Token newToken = new RefreshTokenRequestBuilder()
51+
.withRefreshToken(token.getRefreshToken())
52+
.withUrl(tokenUrl)
6853
.withClientId(clientId)
6954
.fetchToken();
70-
oauth2Token = token;
55+
token = newToken;
7156
return token;
7257
}
7358

cwbi-auth-http-client/src/main/java/hec/army/usace/hec/cwbi/auth/http/client/DirectGrantX509TokenRequestBuilder.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@
2323
*/
2424
package hec.army.usace.hec.cwbi.auth.http.client;
2525

26+
import mil.army.usace.hec.cwms.http.client.ApiConnectionInfo;
2627
import mil.army.usace.hec.cwms.http.client.HttpRequestBuilderImpl;
2728
import mil.army.usace.hec.cwms.http.client.HttpRequestResponse;
2829
import mil.army.usace.hec.cwms.http.client.auth.OAuth2Token;
2930
import mil.army.usace.hec.cwms.http.client.request.HttpRequestExecutor;
3031

3132
import java.io.IOException;
3233

33-
public final class DirectGrantX509TokenRequestBuilder extends TokenRequestBuilder {
34+
public final class DirectGrantX509TokenRequestBuilder extends TokenRequestBuilder<DirectGrantX509TokenRequestBuilder> {
3435

3536
@Override
3637
OAuth2Token retrieveToken() throws IOException {
@@ -43,7 +44,7 @@ OAuth2Token retrieveToken() throws IOException {
4344
.addUsername("")
4445
.buildEncodedString();
4546
HttpRequestExecutor executor =
46-
new HttpRequestBuilderImpl(getUrl())
47+
new HttpRequestBuilderImpl(getTokenUrl())
4748
.post()
4849
.withBody(formBody)
4950
.withMediaType(MEDIA_TYPE);

0 commit comments

Comments
 (0)