diff --git a/pom.xml b/pom.xml index fa1470c96..518d17251 100644 --- a/pom.xml +++ b/pom.xml @@ -48,7 +48,6 @@ 1.6.0 1.7.36 1.2.11 - 10.0.6 5.8.1 @@ -140,23 +139,12 @@ ${commons-lang3.version} + - org.eclipse.jetty - jetty-server - ${jetty.version} + io.github.coffeelibs + tiny-oauth2-client + 0.1.1 - - org.eclipse.jetty - jetty-webapp - ${jetty.version} - - - org.eclipse.jetty - jetty-servlets - ${jetty.version} - - - com.auth0 java-jwt diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index ae2d4c320..7138bcf09 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -28,11 +28,9 @@ module org.cryptomator.desktop { requires com.nulabinc.zxcvbn; requires com.tobiasdiez.easybind; requires dagger; + requires io.github.coffeelibs.tinyoauth2client; requires org.slf4j; requires org.apache.commons.lang3; - requires org.eclipse.jetty.server; - requires org.eclipse.jetty.webapp; - requires org.eclipse.jetty.servlets; /* TODO: filename-based modules: */ requires static javax.inject; /* ugly dagger/guava crap */ diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java deleted file mode 100644 index 80bad9c6e..000000000 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java +++ /dev/null @@ -1,186 +0,0 @@ -package org.cryptomator.ui.keyloading.hub; - -import com.google.common.base.Splitter; -import com.google.common.base.Strings; -import com.google.common.collect.Streams; -import com.google.common.escape.Escaper; -import com.google.common.io.BaseEncoding; -import com.google.common.net.PercentEscaper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Map; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Simple OAuth 2.0 Authentication Code Flow with {@link PKCE}. - *

- * @see RFC 8252 - * @see RFC 6749 - * @see RFC 7636 - */ -class AuthFlow implements AutoCloseable { - - private static final Logger LOG = LoggerFactory.getLogger(AuthFlow.class); - private static final SecureRandom CSPRNG = new SecureRandom(); - private static final BaseEncoding BASE64URL = BaseEncoding.base64Url().omitPadding(); - public static final Escaper QUERY_STRING_ESCAPER = new PercentEscaper("-_.!~*'()@:$,;/?", false); - - private final AuthFlowReceiver receiver; - private final URI authEndpoint; // see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 - private final URI tokenEndpoint; // see https://datatracker.ietf.org/doc/html/rfc6749#section-3.2 - private final String clientId; // see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 - - private AuthFlow(AuthFlowReceiver receiver, HubConfig hubConfig) { - this.receiver = receiver; - this.authEndpoint = URI.create(hubConfig.authEndpoint); - this.tokenEndpoint = URI.create(hubConfig.tokenEndpoint); - this.clientId = hubConfig.clientId; - } - - /** - * Prepares an Authorization Code Flow with PKCE. - *

- * This will start a loopback server, so make sure to {@link #close()} this resource. - * - * @param hubConfig A hub config object containing parameters required for this auth flow - * @return An authorization flow - * @throws Exception In case of any problems starting the server - */ - public static AuthFlow init(HubConfig hubConfig, AuthFlowContext authFlowContext) throws Exception { - var receiver = AuthFlowReceiver.start(hubConfig, authFlowContext); - return new AuthFlow(receiver, hubConfig); - } - - /** - * Runs this Authorization Code Flow. This will take a long time and should be done in a background thread. - * - * @param browser A callback that will open the auth URI in a browser - * @return The access token - * @throws IOException In case of any errors, including failed authentication. - * @throws InterruptedException If this method is interrupted while waiting for responses from the authorization server - */ - public String run(Consumer browser) throws IOException, InterruptedException { - var pkce = new PKCE(); - var authCode = auth(pkce, browser); - return token(pkce, authCode); - } - - private String auth(PKCE pkce, Consumer browser) throws IOException, InterruptedException { - var state = BASE64URL.encode(randomBytes(16)); - var params = Map.of("response_type", "code", // - "client_id", clientId, // - "redirect_uri", receiver.getRedirectUri(), // - "state", state, // - "code_challenge", pkce.challenge, // - "code_challenge_method", PKCE.METHOD // - ); - var uri = appendQueryParams(this.authEndpoint, params); - - // open browser and wait for response - LOG.debug("waiting for user to log into {}", uri); - browser.accept(uri); - var callback = receiver.receive(); - - if (!state.equals(callback.state())) { - throw new IOException("Invalid CSRF Token"); - } else if (callback.error() != null) { - throw new IOException("Authentication failed " + callback.error()); - } else if (callback.code() == null) { - throw new IOException("Received neither authentication code nor error"); - } - return callback.code(); - } - - private String token(PKCE pkce, String authCode) throws IOException, InterruptedException { - var params = Map.of("grant_type", "authorization_code", // - "client_id", clientId, // - "redirect_uri", receiver.getRedirectUri(), // - "code", authCode, // - "code_verifier", pkce.verifier // - ); - var paramStr = paramString(params).collect(Collectors.joining("&")); - var request = HttpRequest.newBuilder(this.tokenEndpoint) // - .header("Content-Type", "application/x-www-form-urlencoded") // - .POST(HttpRequest.BodyPublishers.ofString(paramStr)) // - .build(); - HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofInputStream()); - if (response.statusCode() == 200) { - var json = HttpHelper.parseBody(response); - return json.getAsJsonObject().get("access_token").getAsString(); - } else { - LOG.error("Unexpected HTTP response {}: {}", response.statusCode(), HttpHelper.readBody(response)); - throw new IOException("Unexpected HTTP response code " + response.statusCode()); - } - } - - private URI appendQueryParams(URI uri, Map params) { - var oldParams = Splitter.on("&").omitEmptyStrings().splitToStream(Strings.nullToEmpty(uri.getQuery())); - var newParams = paramString(params); - var query = Streams.concat(oldParams, newParams).collect(Collectors.joining("&")); - try { - return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), query, uri.getFragment()); - } catch (URISyntaxException e) { - throw new IllegalArgumentException("Unable to create URI from given", e); - } - } - - private Stream paramString(Map params) { - return params.entrySet().stream().map(param -> { - var key = QUERY_STRING_ESCAPER.escape(param.getKey()); - var value = QUERY_STRING_ESCAPER.escape(param.getValue()); - return key + "=" + value; - }); - } - - @Override - public void close() throws Exception { - receiver.close(); - } - - /** - * @see RFC 7636 - */ - private static record PKCE(String challenge, String verifier) { - - public static final String METHOD = "S256"; - - public PKCE(String verifier) { - this(BASE64URL.encode(sha256(verifier.getBytes(StandardCharsets.US_ASCII))), verifier); - } - - public PKCE() { - this(BASE64URL.encode(randomBytes(32))); - } - - } - - private static byte[] randomBytes(int len) { - byte[] bytes = new byte[len]; - CSPRNG.nextBytes(bytes); - return bytes; - } - - private static byte[] sha256(byte[] input) { - try { - var digest = MessageDigest.getInstance("SHA-256"); - return digest.digest(input); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Every implementation of the Java platform is required to support SHA-256."); - } - } - -} diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowController.java index 339030cf2..23ecfff91 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowController.java @@ -11,6 +11,7 @@ import org.cryptomator.ui.keyloading.KeyLoadingScoped; import javax.inject.Inject; import javax.inject.Named; import javafx.application.Application; +import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.binding.StringBinding; import javafx.beans.property.ObjectProperty; @@ -75,8 +76,10 @@ public class AuthFlowController implements FxController { } private void setAuthUri(URI uri) { - authUri.set(uri); - browse(); + Platform.runLater(() -> { + authUri.set(uri); + browse(); + }); } private void windowClosed(WindowEvent windowEvent) { diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java deleted file mode 100644 index dc84c450c..000000000 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java +++ /dev/null @@ -1,101 +0,0 @@ -package org.cryptomator.ui.keyloading.hub; - -import org.eclipse.jetty.server.Connector; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; - -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; - -/** - * A basic implementation for RFC 8252, Section 7.3: - *

- * We're spawning a local http server on a system-assigned high port and - * use http://127.0.0.1:{PORT}/callback as a redirect URI. - *

- * Furthermore, we can deliver a html response to inform the user that the - * auth workflow finished and she can close the browser tab. - */ -class AuthFlowReceiver implements AutoCloseable { - - private static final String LOOPBACK_ADDR = "127.0.0.1"; - private static final String CALLBACK_PATH = "/callback"; - - private final Server server; - private final ServerConnector connector; - private final CallbackServlet servlet; - - private AuthFlowReceiver(Server server, ServerConnector connector, CallbackServlet servlet) { - this.server = server; - this.connector = connector; - this.servlet = servlet; - } - - public static AuthFlowReceiver start(HubConfig hubConfig, AuthFlowContext authFlowContext) throws Exception { - var server = new Server(); - var context = new ServletContextHandler(); - - var servlet = new CallbackServlet(hubConfig, authFlowContext); - context.addServlet(new ServletHolder(servlet), CALLBACK_PATH); - - var connector = new ServerConnector(server); - connector.setPort(0); - connector.setHost(LOOPBACK_ADDR); - server.setConnectors(new Connector[]{connector}); - server.setHandler(context); - server.start(); - return new AuthFlowReceiver(server, connector, servlet); - } - - public String getRedirectUri() { - return "http://" + LOOPBACK_ADDR + ":" + connector.getLocalPort() + CALLBACK_PATH; - } - - public Callback receive() throws InterruptedException { - return servlet.callback.take(); - } - - @Override - public void close() throws Exception { - server.stop(); - } - - public static record Callback(String error, String code, String state) { - - } - - private static class CallbackServlet extends HttpServlet { - - private final BlockingQueue callback = new LinkedBlockingQueue<>(); - private final HubConfig hubConfig; - private final AuthFlowContext authFlowContext; - - public CallbackServlet(HubConfig hubConfig, AuthFlowContext authFlowContext) { - this.hubConfig = hubConfig; - this.authFlowContext = authFlowContext; - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { - var error = req.getParameter("error"); - var code = req.getParameter("code"); - var state = req.getParameter("state"); - - res.setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY); - if (error == null && code != null) { - res.setHeader("Location", hubConfig.authSuccessUrl + "&device=" + authFlowContext.deviceId()); - } else { - res.setHeader("Location", hubConfig.authErrorUrl + "&device=" + authFlowContext.deviceId()); - } - - callback.add(new Callback(error, code, state)); - } - } - -} diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowTask.java b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowTask.java index be41df2dd..b9b4fdf0b 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowTask.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowTask.java @@ -1,12 +1,16 @@ package org.cryptomator.ui.keyloading.hub; -import javafx.application.Platform; +import com.google.gson.JsonParser; +import io.github.coffeelibs.tinyoauth2client.AuthFlow; + import javafx.concurrent.Task; +import java.io.IOException; import java.net.URI; import java.util.function.Consumer; class AuthFlowTask extends Task { + private final HubConfig hubConfig; private final AuthFlowContext authFlowContext; private final Consumer redirectUriConsumer; @@ -23,11 +27,13 @@ class AuthFlowTask extends Task { } @Override - protected String call() throws Exception { - try (var authFlow = AuthFlow.init(hubConfig, authFlowContext)) { - return authFlow.run(uri -> Platform.runLater(() -> redirectUriConsumer.accept(uri))); - } + protected String call() throws IOException, InterruptedException { + // TODO configure redirectURIs with deviceId from authFlowContext + var response = AuthFlow.asClient(hubConfig.clientId) // + .authorize(URI.create(hubConfig.authEndpoint), redirectUriConsumer) // + .getAccessToken(URI.create(hubConfig.tokenEndpoint)); + var json = JsonParser.parseString(response); + return json.getAsJsonObject().get("access_token").getAsString(); } - private final HubConfig hubConfig; } diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/HttpHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/HttpHelper.java index 7de2902be..51f3662a7 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/HttpHelper.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/HttpHelper.java @@ -20,12 +20,4 @@ class HttpHelper { } } - public static JsonElement parseBody(HttpResponse response) throws IOException { - try (InputStream in = response.body(); Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { - return JsonParser.parseReader(reader); - } catch (JsonParseException e) { - throw new IOException("Failed to parse JSON", e); - } - } - } diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java index be4c3b791..2bdb772a6 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java @@ -8,7 +8,6 @@ import org.cryptomator.ui.common.FxmlFile; import org.cryptomator.ui.common.FxmlScene; import org.cryptomator.ui.keyloading.KeyLoading; import org.cryptomator.ui.keyloading.KeyLoadingScoped; -import org.eclipse.jetty.io.RuntimeIOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -82,7 +81,7 @@ public class ReceiveKeyController implements FxController { default -> throw new IOException("Unexpected response " + response.statusCode()); } } catch (IOException e) { - throw new RuntimeIOException(e); + throw new UncheckedIOException(e); } } diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java deleted file mode 100644 index f2b9e9cd5..000000000 --- a/src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.cryptomator.ui.keyloading.hub; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AuthFlowIntegrationTest { - - static { - System.setProperty("LOGLEVEL", "INFO"); - } - - private static final Logger LOG = LoggerFactory.getLogger(AuthFlowIntegrationTest.class); - - @Test - @Disabled // only to be run manually - public void testRetrieveToken() throws Exception { - var hubConfig = new HubConfig(); - hubConfig.authEndpoint = "http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/auth"; - hubConfig.tokenEndpoint = "http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/token"; - hubConfig.clientId = "cryptomator-hub"; - hubConfig.authSuccessUrl = "http://localhost:3000/#/unlock-success?vault=vaultId"; - hubConfig.authErrorUrl = "http://localhost:3000/#/unlock-error?vault=vaultId"; - - try (var authFlow = AuthFlow.init(hubConfig, new AuthFlowContext("deviceId"))) { - var token = authFlow.run(uri -> { - LOG.info("Visit {} to authenticate", uri); - }); - LOG.info("Received token {}", token); - Assertions.assertNotNull(token); - } - } - -} \ No newline at end of file