From f9c2807ce1c79dc06a6bb779700b4164f58f5e43 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Thu, 12 Aug 2021 10:54:58 +0200 Subject: [PATCH] added basic OAuth 2.0 Authorization Code Flow + PKCE impl --- .../ui/keyloading/hub/AuthFlow.java | 209 ++++++++++++++++++ .../ui/keyloading/hub/AuthFlowReceiver.java | 105 +++++++++ .../hub/AuthFlowIntegrationTest.java | 34 +++ 3 files changed, 348 insertions(+) create mode 100644 src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java create mode 100644 src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java create mode 100644 src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java new file mode 100644 index 000000000..343c12348 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java @@ -0,0 +1,209 @@ +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.io.CharStreams; +import com.google.common.net.PercentEscaper; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +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; + private final URI tokenEndpoint; + private final String clientId; + + private AuthFlow(AuthFlowReceiver receiver, URI authEndpoint, URI tokenEndpoint, String clientId) { + this.receiver = receiver; + this.authEndpoint = authEndpoint; + this.tokenEndpoint = tokenEndpoint; + this.clientId = clientId; + } + + /** + * Prepares an Authorization Code Flow with PKCE. + *

+ * This will start a loopback server, so make sure to {@link #close()} this resource. + * + * @param authEndpoint Address of the Authorization Endpoint + * @param tokenEndpoint Address of the Token Endpoint + * @param clientId The client_id + * @return An authorization + * @throws Exception + */ + public static AuthFlow init(URI authEndpoint, URI tokenEndpoint, String clientId) throws Exception { + var receiver = AuthFlowReceiver.start(); + return new AuthFlow(receiver, authEndpoint, tokenEndpoint, clientId); + } + + /** + * 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", "cryptomator-hub", // TODO + "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 = parseBody(response); + return json.getAsJsonObject().get("access_token").getAsString(); + } else { + LOG.error("Unexpected HTTP response {}: {}", response.statusCode(), readBody(response)); + throw new IOException("Unexpected HTTP response code " + response.statusCode()); + } + } + + private String readBody(HttpResponse response) throws IOException { + try (var in = response.body(); var reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + return CharStreams.toString(reader); + } + } + + private 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); + } + } + + 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/AuthFlowReceiver.java b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java new file mode 100644 index 000000000..1ba895ae4 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java @@ -0,0 +1,105 @@ +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}/success 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 static final String HTML_SUCCESS = """ + + + OAuth 2.0 Authentication Token Received + + +

Received verification code. You may now close this window.

+ + + """; + + 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() throws Exception { + var server = new Server(); + var context = new ServletContextHandler(); + +// var corsFilter = new FilterHolder(new CrossOriginFilter()); +// corsFilter.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*"); // TODO restrict to hub host +// context.addFilter(corsFilter, "/*", EnumSet.of(DispatcherType.REQUEST)); + + var servlet = new CallbackServlet(); + 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<>(); + + @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"); + + // TODO 302 use redirect to configurable site + res.setContentType("text/html;charset=utf-8"); + res.getWriter().write(HTML_SUCCESS); + res.getWriter().flush(); + + callback.add(new Callback(error, code, state)); + } + + } + +} diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java new file mode 100644 index 000000000..af34d0c0a --- /dev/null +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java @@ -0,0 +1,34 @@ +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; + +import java.net.URI; + +public class AuthFlowIntegrationTest { + + static { + System.setProperty("LOGLEVEL", "INFO"); + } + + private static final Logger LOG = LoggerFactory.getLogger(AuthFlowIntegrationTest.class); + private static final URI AUTH_URI = URI.create("http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/auth"); + private static final URI TOKEN_URI = URI.create("http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/token"); + private static final String CLIENT_ID = "cryptomator-hub"; + + @Test + @Disabled // only to be run manually + public void testRetrieveToken() throws Exception { + try (var authFlow = AuthFlow.init(AUTH_URI, TOKEN_URI, CLIENT_ID)) { + 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