added basic OAuth 2.0 Authorization Code Flow + PKCE impl

This commit is contained in:
Sebastian Stenzel
2021-08-12 10:54:58 +02:00
parent 8075d33d39
commit f9c2807ce1
3 changed files with 348 additions and 0 deletions

View File

@@ -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}.
* <p>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc8252">RFC 8252</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a>
*/
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.
* <p>
* This will start a loopback server, so make sure to {@link #close()} this resource.
*
* @param authEndpoint Address of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.1">Authorization Endpoint</a>
* @param tokenEndpoint Address of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
* @param clientId The <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1"><code>client_id</code></a>
* @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<URI> browser) throws IOException, InterruptedException {
var pkce = new PKCE();
var authCode = auth(pkce, browser);
return token(pkce, authCode);
}
private String auth(PKCE pkce, Consumer<URI> 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<InputStream> 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<InputStream> response) throws IOException {
try (var in = response.body(); var reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
}
private JsonElement parseBody(HttpResponse<InputStream> 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<String, String> 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<String> paramString(Map<String, String> 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 <a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a>
*/
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.");
}
}
}

View File

@@ -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:
* <p>
* We're spawning a local http server on a system-assigned high port and
* use <code>http://127.0.0.1:{PORT}/success</code> as a redirect URI.
* <p>
* 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 = """
<html>
<head>
<title>OAuth 2.0 Authentication Token Received</title>
</head>
<body>
<p>Received verification code. You may now close this window.</p>
</body>
</html>
""";
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> 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));
}
}
}