diff --git a/src/main/java/com/sforce/oauth/flow/WebServerFlow.java b/src/main/java/com/sforce/oauth/flow/WebServerFlow.java new file mode 100644 index 00000000..b921656d --- /dev/null +++ b/src/main/java/com/sforce/oauth/flow/WebServerFlow.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and + * the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or + * promote products derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package com.sforce.oauth.flow; + +import com.sforce.oauth.exception.OAuthException; +import com.sforce.oauth.model.OAuthCallbackResult; +import com.sforce.oauth.model.OAuthTokenResponse; +import com.sforce.oauth.server.OAuthCallbackServer; +import com.sforce.ws.ConnectionException; +import com.sforce.ws.ConnectorConfig; + +import java.awt.*; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static java.awt.Desktop.*; + +public class WebServerFlow extends AbstractOAuthFlow { + + private static final String GRANT_TYPE = "authorization_code"; + private static final String RESPONSE_TYPE = "code"; + + private String state; + private String codeChallenge; + private String codeVerifier; + private OAuthCallbackResult oAuthCallbackResult; + + @Override + public OAuthTokenResponse getToken(ConnectorConfig config) throws OAuthException, ConnectionException { + if (!isConfigValid(config)) { + throw new OAuthException("Invalid OAuth configuration: missing required parameters (client_id, token_endpoint, authorization_code, or redirect_uri)"); + } + + try { + final URI authorizationURI = buildAuthorizationUri(config); + + OAuthCallbackServer oAuthCallbackServer = new OAuthCallbackServer(new URI(config.getRedirectUri()), this.state); + oAuthCallbackServer.start(); + + launchBrowser(authorizationURI); + + this.oAuthCallbackResult = oAuthCallbackServer.waitForCallback(60000L); + if (!oAuthCallbackResult.isSuccess()) { + throw new OAuthException("Authorization failed: " + oAuthCallbackResult.getErrorText()); + } + + final Map requestHeaders = createHeaders(config); + final String requestBody = createRequestBody(config); + final String tokenEndpoint = config.getTokenEndpoint(); + + return executeTokenRequest(config, tokenEndpoint, requestHeaders, requestBody); + } catch (IOException ex) { + throw new ConnectionException("Error establishing token request connection.", ex); + } catch (Exception ex) { + if (ex instanceof OAuthException) throw (OAuthException) ex; + throw new ConnectionException("An unexpected error occurred during the OAuth flow.", ex); + } + } + + private URI buildAuthorizationUri(ConnectorConfig config) throws NoSuchAlgorithmException, URISyntaxException { + StringBuilder authorizationURLBuilder = + new StringBuilder(config.getAuthEndpoint()); + String encodedRedirectURI = + URLEncoder.encode(config.getRedirectUri(), StandardCharsets.UTF_8); + this.state = generateState(); + + authorizationURLBuilder + .append("?response_type=").append(RESPONSE_TYPE) + .append("&client_id=").append(config.getClientId()) + .append("&redirect_uri=").append(encodedRedirectURI) + .append("&state=").append(state); + + if (config.isEnablePKCE()) { + this.codeVerifier = generateCodeVerifier(); + this.codeChallenge = generateCodeChallenge(codeVerifier); + + authorizationURLBuilder + .append("&code_challenge=").append(codeChallenge) + .append("&code_challenge_method=S256"); + } + return new URI(authorizationURLBuilder.toString()); + } + + @Override + protected boolean isConfigValid(ConnectorConfig config) { + + if (config == null) { + return false; + } + + String clientId = config.getClientId(); + String redirectURI = config.getRedirectUri(); + String tokenEndpoint = config.getTokenEndpoint(); + String authorizationEndpoint = config.getAuthorizationEndpoint(); + + return clientId != null && !clientId.trim().isEmpty() && + redirectURI != null && !redirectURI.trim().isEmpty() && + tokenEndpoint != null && !tokenEndpoint.trim().isEmpty() && + authorizationEndpoint != null && !authorizationEndpoint.trim().isEmpty(); + } + + @Override + protected String createRequestBody(ConnectorConfig config) { + StringBuilder body = new StringBuilder(); + + body.append("grant_type=").append(GRANT_TYPE); + body.append("&code=").append(oAuthCallbackResult.getCode()); + body.append("&client_id=").append(config.getClientId()); + body.append("&redirect_uri=").append(config.getRedirectUri()); + + String clientSecret = config.getClientSecret(); + if (clientSecret != null && !clientSecret.trim().isEmpty()) { + body.append("&client_secret=").append(clientSecret); + } + + if (config.isEnablePKCE()) { + body.append("&code_verifier=").append(codeVerifier); + } + return body.toString(); + } + + @Override + protected Map createHeaders(ConnectorConfig config) { + Map requestHeaders = new HashMap<>(); + requestHeaders.put(CONTENT_TYPE_HEADER, "application/x-www-form-urlencoded"); + requestHeaders.put(ACCEPT_HEADER, "application/json"); + return requestHeaders; + } + + + private String generateState() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String generateCodeVerifier() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String generateCodeChallenge(String verifier) throws NoSuchAlgorithmException { + byte[] bytes = verifier.getBytes(StandardCharsets.US_ASCII); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(bytes, 0, bytes.length); + byte[] digest = md.digest(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + private void launchBrowser(URI uri) throws IOException { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Action.BROWSE)) { + Desktop.getDesktop().browse(uri); + } + } +} diff --git a/src/main/java/com/sforce/oauth/model/OAuthCallbackResult.java b/src/main/java/com/sforce/oauth/model/OAuthCallbackResult.java new file mode 100644 index 00000000..50a8bae6 --- /dev/null +++ b/src/main/java/com/sforce/oauth/model/OAuthCallbackResult.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and + * the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or + * promote products derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package com.sforce.oauth.model; + +public class OAuthCallbackResult { + private final boolean success; + private final String code; + private final String state; + private final String error; + private final String errorDescription; + + private OAuthCallbackResult(boolean success, String code, String state, + String error, String errorDescription) { + this.success = success; + this.code = code; + this.state = state; + this.error = error; + this.errorDescription = errorDescription; + } + + public static OAuthCallbackResult createSuccess(String code, String state) { + return new OAuthCallbackResult(true, code, state, null, null); + } + + public static OAuthCallbackResult createError(String error, String errorDescription) { + return new OAuthCallbackResult(false, null, null, error, errorDescription); + } + + public boolean isSuccess() { + return success; + } + + public String getCode() { + return code; + } + + public String getState() { + return state; + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + + public String getErrorText() { + return String.format("error:%s Description:%s", error, errorDescription); + } +} \ No newline at end of file diff --git a/src/main/java/com/sforce/oauth/server/OAuthCallbackHandler.java b/src/main/java/com/sforce/oauth/server/OAuthCallbackHandler.java new file mode 100644 index 00000000..882db099 --- /dev/null +++ b/src/main/java/com/sforce/oauth/server/OAuthCallbackHandler.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and + * the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or + * promote products derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package com.sforce.oauth.server; + +import com.sforce.oauth.exception.OAuthException; +import com.sforce.oauth.model.OAuthCallbackResult; +import com.sforce.oauth.util.OAuthUtil; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +public class OAuthCallbackHandler implements HttpHandler { + + private final CompletableFuture authCodeFuture; + private final String expectedState; + + public OAuthCallbackHandler() { + this(new CompletableFuture<>(), null); + } + + public OAuthCallbackHandler(CompletableFuture authCodeFuture, String expectedState) { + this.authCodeFuture = authCodeFuture; + this.expectedState = expectedState; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + + OAuthCallbackResult oAuthCallbackResult = buildOauthCallbackResult(httpExchange); + if (oAuthCallbackResult.isSuccess()) { + sendSuccessResponse(httpExchange); + authCodeFuture.complete(oAuthCallbackResult); + } else { + sendErrorResponse(httpExchange, oAuthCallbackResult.getErrorText()); + authCodeFuture.completeExceptionally(new OAuthException(oAuthCallbackResult.getError())); + } + } + + private OAuthCallbackResult buildOauthCallbackResult(HttpExchange httpExchange) { + + String queryString = httpExchange.getRequestURI().getQuery(); + + if (queryString == null || queryString.trim().isEmpty()) { + throw new RuntimeException("No query parameters found"); + } + Map queryParams = OAuthUtil.getQueryParams(queryString); + + if (!hasValidStateParam(queryParams.get("state"))) { + return OAuthCallbackResult.createError("invalid_state", "State parameter mismatch"); + } + + + if (queryParams.containsKey("code")) { + return OAuthCallbackResult.createSuccess(queryParams.get("code"), queryParams.get("state")); + } else if (queryParams.containsKey("error") || queryParams.containsKey("error_description")) { + return OAuthCallbackResult.createError(queryParams.get("error"), queryParams.get("error_description")); + } + return OAuthCallbackResult.createError("invalid_query_string", "Could not parse request query string: " + queryString); + } + + private boolean hasValidStateParam(String actualState) { + if (expectedState == null) { + return true; // bypass state check + } + return Objects.equals(expectedState, actualState); + } + + private void sendSuccessResponse(HttpExchange exchange) throws IOException { + String response = getSuccessPage(); + sendResponse(exchange, 200, response); + } + + private void sendErrorResponse(HttpExchange exchange, String errorMessage) throws IOException { + String response = getErrorPage(errorMessage); + sendResponse(exchange, 400, response); + } + + private void sendResponse(HttpExchange exchange, int statusCode, String content) throws IOException { + byte[] responseBytes = content.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, responseBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBytes); + } + } + + private String getSuccessPage() { + return "" + + "" + + "" + + "Authorization Successful" + + "" + + "" + + "" + + "

Authorization Successful!

" + + "

You have successfully authorized the application.

" + + "

You can close this window and return to the application.

" + + "" + + ""; + } + + private String getErrorPage(String errorMessage) { + return "" + + "" + + "" + + "Authorization Error" + + "" + + "" + + "" + + "

Authorization Error

" + + "

An error occurred during authorization: " + errorMessage + "

" + + "" + + ""; + } +} diff --git a/src/main/java/com/sforce/oauth/server/OAuthCallbackServer.java b/src/main/java/com/sforce/oauth/server/OAuthCallbackServer.java new file mode 100644 index 00000000..fbd55611 --- /dev/null +++ b/src/main/java/com/sforce/oauth/server/OAuthCallbackServer.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided + * that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this list of conditions and the + * following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and + * the following disclaimer in the documentation and/or other materials provided with the distribution. + * + * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or + * promote products derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package com.sforce.oauth.server; + +import com.sforce.oauth.model.OAuthCallbackResult; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class OAuthCallbackServer { + + private final CompletableFuture callbackFuture; + private final ExecutorService executor; + private final HttpServer server; + + public OAuthCallbackServer(int port, String callbackPath, String expectedState) throws IOException { + + this.callbackFuture = new CompletableFuture<>(); + OAuthCallbackHandler defaultHandler = new OAuthCallbackHandler(this.callbackFuture, expectedState); + + this.executor = Executors.newSingleThreadExecutor(); + + this.server = HttpServer.create(new InetSocketAddress(port), 0); + this.server.setExecutor(executor); + this.server.createContext(callbackPath, defaultHandler); + } + + public OAuthCallbackServer(URI uri, String expectedState) throws IOException { + this(uri.getPort(), uri.getPath(), expectedState); + } + + public void start() { + server.start(); + } + + public void stop() { + if (server != null) { + server.stop(0); + + executor.shutdown(); + try { + // Wait for existing tasks to terminate + if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + executor.shutdownNow(); + } + } + } + + public OAuthCallbackResult waitForCallback(long timeoutMillis) throws ExecutionException, InterruptedException, TimeoutException { + return callbackFuture.get(timeoutMillis, TimeUnit.MILLISECONDS); + } +} diff --git a/src/main/java/com/sforce/oauth/util/OAuthUtil.java b/src/main/java/com/sforce/oauth/util/OAuthUtil.java index 4000d6f8..0e055846 100644 --- a/src/main/java/com/sforce/oauth/util/OAuthUtil.java +++ b/src/main/java/com/sforce/oauth/util/OAuthUtil.java @@ -28,7 +28,10 @@ import com.sforce.ws.util.Base64; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; public class OAuthUtil { @@ -53,4 +56,22 @@ public static String buildBasicAuthHeader(String clientId, String clientSecret) byte[] encodedBytes = Base64.encode(credentials.getBytes(StandardCharsets.UTF_8)); return "Basic " + new String(encodedBytes, StandardCharsets.UTF_8); } + + public static Map getQueryParams(String queryString) { + if (queryString == null || queryString.trim().isEmpty()) { + return new HashMap<>(); + } + Map queryParams = new HashMap<>(); + String[] params = queryString.split("&"); + + for (String param : params) { + String[] keyValue = param.split("=", 2); + if (keyValue.length == 2) { + String key = URLDecoder.decode(keyValue[0], StandardCharsets.UTF_8); + String value = URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8); + queryParams.put(key, value); + } + } + return queryParams; + } } diff --git a/src/main/java/com/sforce/ws/ConnectorConfig.java b/src/main/java/com/sforce/ws/ConnectorConfig.java index 0741e4d9..0f17666a 100644 --- a/src/main/java/com/sforce/ws/ConnectorConfig.java +++ b/src/main/java/com/sforce/ws/ConnectorConfig.java @@ -168,8 +168,11 @@ public void close() throws IOException { // OAuth fields private String clientId; private String clientSecret; + private String authorizationEndpoint; private String tokenEndpoint; private String refreshToken; + private String redirectUri; + private boolean enablePKCE; public static final ConnectorConfig DEFAULT = new ConnectorConfig(); @@ -590,6 +593,14 @@ public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; } + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public void setAuthorizationEndpoint(String authorizationEndpoint) { + this.authorizationEndpoint = authorizationEndpoint; + } + public String getTokenEndpoint() { return tokenEndpoint; } @@ -605,4 +616,20 @@ public String getRefreshToken() { public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public boolean isEnablePKCE() { + return enablePKCE; + } + + public void setEnablePKCE(boolean enablePKCE) { + this.enablePKCE = enablePKCE; + } }