Skip to content

Commit

Permalink
SQL-1939: Create spawnable RFC8252 server (#248)
Browse files Browse the repository at this point in the history
* SQL-1939: Create spawnable RFC8252 server

* Update src/main/java/com/mongodb/jdbc/oidc/RFC8252HttpServer.java

Co-authored-by: Patrick Meredith <pmeredit@protonmail.com>

* Update src/main/java/com/mongodb/jdbc/oidc/RFC8252HttpServer.java

Co-authored-by: Natacha Bagnard <91975317+nbagnard@users.noreply.github.com>

* SQL-1939: Implement review comments

* SQL-1939: spottlessApply

---------

Co-authored-by: Patrick Meredith <pmeredit@protonmail.com>
Co-authored-by: Natacha Bagnard <91975317+nbagnard@users.noreply.github.com>
  • Loading branch information
3 people committed Apr 11, 2024
1 parent 1f89a68 commit 39ae6ca
Show file tree
Hide file tree
Showing 9 changed files with 1,951 additions and 0 deletions.
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ task runDataLoader(type: JavaExec) {
main = javaDataLoader
}

task runServer(type: JavaExec) {
classpath = sourceSets.main.runtimeClasspath
main = 'com.mongodb.jdbc.oidc.TestRFC8252ServerMain'
}

jar {
manifest {
attributes('Implementation-Title': project.name,
Expand All @@ -162,6 +167,7 @@ dependencies {
api "org.mongodb:mongodb-driver-sync:$mongodbDriverVersion"
integrationTestImplementation "org.junit.jupiter:junit-jupiter:$junitJupiterVersion"
integrationTestImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: junitJupiterVersion
implementation group: 'org.thymeleaf', name: 'thymeleaf', version: thymeLeafVersion
}

def getReleaseVersion() {
Expand Down
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ guavaVersion = 32.0.1-jre
lang3Version = 3.0
nexusDomain = http://localhost:8081/nexus
snakeYamlVersion = 2.2
thymeLeafVersion = 3.1.2.RELEASE
# to disable publication of both SHA-256 and SHA-512 checksums which causes error in maven release
systemProp.org.gradle.internal.publish.checksums.insecure = true
76 changes: 76 additions & 0 deletions src/main/java/com/mongodb/jdbc/oidc/OidcResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2024-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.mongodb.jdbc.oidc;

// TODO: This class is a placeholder for the OIDC response,
// and will be removed when Java Driver OIDC support is added.
public class OidcResponse {
private String code;
private String state;
private String error;
private String errorDescription;

public String getCode() {
return code;
}

public String getState() {
return state;
}

public String getError() {
return error;
}

public String getErrorDescription() {
return errorDescription;
}

public void setCode(String code) {
this.code = code;
}

public void setState(String state) {
this.state = state;
}

public void setError(String error) {
this.error = error;
}

public void setErrorDescription(String errorDescription) {
this.errorDescription = errorDescription;
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
if (code != null) {
sb.append("Code: ").append(code).append("\n");
}
if (state != null) {
sb.append("State: ").append(state).append("\n");
}
if (error != null) {
sb.append("Error: ").append(error).append("\n");
}
if (errorDescription != null) {
sb.append("Error Description: ").append(errorDescription).append("\n");
}
return sb.toString();
}
}
272 changes: 272 additions & 0 deletions src/main/java/com/mongodb/jdbc/oidc/RFC8252HttpServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/*
* Copyright 2024-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.mongodb.jdbc.oidc;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;

/**
* The RFC8252HttpServer class implements an OIDC (OpenID Connect) server based on RFC 8252. It
* handles the OIDC authorization code flow by providing endpoints for the callback and redirection.
* The server listens on a specified port (default is 27017) and processes incoming HTTP requests.
*/
public class RFC8252HttpServer {
public static final int DEFAULT_REDIRECT_PORT = 27097;

// SQL-2008: make sure this page exists and possibly update the link if the
// docs team has a preference
private static final String LOGIN_ERROR_URI =
"https://www.mongodb.com/docs/atlas/security-oidc";
private static final String PRODUCT_DOCS_LINK =
"https://www.mongodb.com/docs/atlas/data-federation/query/sql/drivers/odbc/connect";
private static final String PRODUCT_DOCS_NAME = "Atlas SQL ODBC Driver";

// OIDC response parameters
private static final String CODE = "code";
private static final String LOCATION = "Location";
private static final String STATE = "state";

// template variables
private static final String PRODUCT_DOCS_LINK_KEY = "product_docs_link";
private static final String PRODUCT_DOCS_NAME_KEY = "product_docs_name";
private static final String ERROR_URI_KEY = "error_uri";
private static final String ERROR_KEY = "error";
private static final String ERROR_DESCRIPTION_KEY = "error_description";

// server endpoints
private static final String ACCEPTED_ENDPOINT = "/accepted";
private static final String CALLBACK_ENDPOINT = "/callback";
private static final String REDIRECT_ENDPOINT = "/redirect";

private HttpServer server;
private final TemplateEngine templateEngine;
private final BlockingQueue<OidcResponse> oidcResponseQueue;

public RFC8252HttpServer() {
templateEngine = createTemplateEngine();
oidcResponseQueue = new LinkedBlockingQueue<>();
}

/**
* Starts the HTTP server and sets up the necessary contexts and handlers.
*
* @throws IOException if an I/O error occurs while creating or starting the server
*/
public void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(DEFAULT_REDIRECT_PORT), 0);

server.createContext(CALLBACK_ENDPOINT, new CallbackHandler());
server.createContext(REDIRECT_ENDPOINT, new CallbackHandler());
server.createContext(ACCEPTED_ENDPOINT, new AcceptedHandler());
server.setExecutor(null);
server.start();
}

/**
* Blocks until an OIDC response is available in the queue.
*
* @return the OIDC response
* @throws InterruptedException if the current thread is interrupted while waiting
*/
public OidcResponse getOidcResponse() throws InterruptedException {
return oidcResponseQueue.take();
}

public void stop() {
if (server != null) {
server.stop(0);
}
}

/**
* Creates and configures the template engine.
*
* @return the configured template engine
*/
private TemplateEngine createTemplateEngine() {
TemplateEngine templateEngine = new TemplateEngine();
ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
templateResolver.setPrefix("/templates/");
templateResolver.setSuffix(".html");
templateEngine.setTemplateResolver(templateResolver);
return templateEngine;
}

/** HTTP handler for handling the callback and redirect endpoints. */
private class CallbackHandler implements HttpHandler {

@Override
public void handle(HttpExchange exchange) throws IOException {
Map<String, String> queryParams = parseQueryParams(exchange);
OidcResponse oidcResponse = new OidcResponse();

if (queryParams.containsKey(CODE)) {
oidcResponse.setCode(queryParams.get(CODE));
oidcResponse.setState(queryParams.getOrDefault(STATE, ""));
if (!putOidcResponse(exchange, oidcResponse)) {
return;
}
// This will hide the code and state from the URL bar by doing a redirect
// to the /accepted page rather than rendering the accepted page directly
exchange.getResponseHeaders().set(LOCATION, ACCEPTED_ENDPOINT);
sendResponse(exchange, "", HttpURLConnection.HTTP_MOVED_TEMP);
} else if (queryParams.containsKey(ERROR_KEY)) {
oidcResponse.setError(queryParams.get(ERROR_KEY));
oidcResponse.setErrorDescription(
queryParams.getOrDefault(ERROR_DESCRIPTION_KEY, "Unknown error"));
if (!putOidcResponse(exchange, oidcResponse)) {
return;
}
Context context = new Context();
context.setVariable(ERROR_URI_KEY, LOGIN_ERROR_URI);
context.setVariable(PRODUCT_DOCS_LINK_KEY, PRODUCT_DOCS_LINK);
context.setVariable(PRODUCT_DOCS_NAME_KEY, PRODUCT_DOCS_NAME);
context.setVariable(ERROR_KEY, queryParams.get(ERROR_KEY));
context.setVariable(
ERROR_DESCRIPTION_KEY,
queryParams.getOrDefault(ERROR_DESCRIPTION_KEY, "Unknown error"));
String errorHtml = templateEngine.process("OIDCErrorTemplate", context);
sendResponse(exchange, errorHtml, HttpURLConnection.HTTP_BAD_REQUEST);

} else {
oidcResponse.setError("Not found");
String allParams =
queryParams
.entrySet()
.stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.reduce((param1, param2) -> param1 + ", " + param2)
.orElse("No parameters");
oidcResponse.setErrorDescription("Not found. Parameters: " + allParams);
if (!putOidcResponse(exchange, oidcResponse)) {
return;
}
Context context = new Context();
context.setVariable(PRODUCT_DOCS_LINK_KEY, PRODUCT_DOCS_LINK);
context.setVariable(PRODUCT_DOCS_NAME_KEY, PRODUCT_DOCS_NAME);
String notFoundHtml = templateEngine.process("OIDCNotFoundTemplate", context);
sendResponse(exchange, notFoundHtml, HttpURLConnection.HTTP_NOT_FOUND);
}
}
}

/** HTTP handler for handling the accepted endpoint. */
private class AcceptedHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
Context context = new Context();
context.setVariable(PRODUCT_DOCS_LINK_KEY, PRODUCT_DOCS_LINK);
context.setVariable(PRODUCT_DOCS_NAME_KEY, PRODUCT_DOCS_NAME);
String acceptedHtml = templateEngine.process("OIDCAcceptedTemplate", context);
sendResponse(exchange, acceptedHtml, HttpURLConnection.HTTP_OK);
}
}

/**
* Parses the query parameters from the HTTP exchange.
*
* @param exchange the HTTP exchange
* @return a map containing the parsed query parameters
* @throws UnsupportedEncodingException if the encoding is not supported
*/
private Map<String, String> parseQueryParams(HttpExchange exchange)
throws UnsupportedEncodingException {
Map<String, String> queryParams = new HashMap<>();
String rawQuery = exchange.getRequestURI().getRawQuery();

if (rawQuery != null) {
String[] params = rawQuery.split("&");
for (String param : params) {
int equalsIndex = param.indexOf('=');
if (equalsIndex > 0) {
String key = param.substring(0, equalsIndex);
String encodedValue = param.substring(equalsIndex + 1);
String value = URLDecoder.decode(encodedValue, "UTF-8");
queryParams.put(key, value);
} else {
queryParams.put(param, "");
}
}
}
return queryParams;
}

/**
* Puts the OIDC response into the blocking queue. If the queue is full, an error response is
* sent to the client and the HttpExchange is closed.
*
* @param exchange the HTTP exchange
* @param oidcResponse the OIDC response to put into the queue
* @return true if the response was successfully put into the queue, false otherwise
* @throws IOException if an I/O error occurs while sending a response
*/
private boolean putOidcResponse(HttpExchange exchange, OidcResponse oidcResponse)
throws IOException {
try {
oidcResponseQueue.put(oidcResponse);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// sendResponse will close the exchange
sendResponse(exchange, "<html><body><h1>Internal Server Error</h1></body></html>", 500);
return false;
}
}

/**
* Sends an HTTP response with the specified content and status code.
*
* @param exchange the HTTP exchange
* @param response the response content
* @param statusCode the HTTP status code
* @throws IOException if an I/O error occurs while sending the response
*/
private void sendResponse(HttpExchange exchange, String response, int statusCode)
throws IOException {
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
try {
exchange.sendResponseHeaders(
statusCode, response.getBytes(StandardCharsets.UTF_8).length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(response.getBytes(StandardCharsets.UTF_8));
}
} catch (IOException e) {
Logger logger = Logger.getLogger(RFC8252HttpServer.class.getName());
logger.log(Level.SEVERE, "Error sending response", e);
throw e;
} finally {
exchange.close();
}
}
}
Loading

0 comments on commit 39ae6ca

Please sign in to comment.