diff --git a/e2e-test/clients.py b/e2e-test/clients.py
index a14ba01d..63b441eb 100644
--- a/e2e-test/clients.py
+++ b/e2e-test/clients.py
@@ -65,7 +65,7 @@ class FhirProxyClient:
"""
def __init__(self, host: str = "http://localhost", port: int = 8080) -> None:
- self.base_url = "{}:{}".format(host, port)
+ self.base_url = "{}:{}/fhir".format(host, port)
self.session = _setup_session(self.base_url)
def get_resource_count(
diff --git a/exec/README.md b/exec/README.md
new file mode 100644
index 00000000..e0cab368
--- /dev/null
+++ b/exec/README.md
@@ -0,0 +1,12 @@
+# Sample application
+
+This module is to show simple examples of how to use the FHIR Gateway. The
+minimal application is
+[MainApp](src/main/java/com/google/fhir/gateway/MainApp.java). With this single
+class, you can create an executable app with the Gateway [server](../server) and
+all of the `AccessChecker` [plugins](../plugins), namely
+[ListAccessChecker](../plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java)
+and
+[PatientAccessChecker](../plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java).
+
+Two other classes are provided to show how to implement custom endpoints.
diff --git a/exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java b/exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java
new file mode 100644
index 00000000..89a826d5
--- /dev/null
+++ b/exec/src/main/java/com/google/fhir/gateway/CustomFhirEndpointExample.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021-2023 Google LLC
+ *
+ * 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.google.fhir.gateway;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.context.FhirVersionEnum;
+import ca.uhn.fhir.parser.IParser;
+import com.auth0.jwt.interfaces.Claim;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import javax.servlet.ServletException;
+import javax.servlet.annotation.WebServlet;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.hl7.fhir.instance.model.api.IBaseResource;
+import org.hl7.fhir.r4.model.ListResource;
+import org.hl7.fhir.r4.model.ListResource.ListEntryComponent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is an example servlet that requires a valid JWT to be present as the Bearer Authorization
+ * header. Although it is not a standard FHIR query, but it uses the FHIR server to construct the
+ * response. In this example, it inspects the JWT and depending on its claims, constructs the list
+ * of Patient IDs that the user has access to.
+ *
+ *
The two types of tokens resemble {@link com.google.fhir.gateway.plugin.ListAccessChecker} and
+ * {@link com.google.fhir.gateway.plugin.PatientAccessChecker} expected tokens. But those are just
+ * picked as examples and this custom endpoint is independent of any {@link
+ * com.google.fhir.gateway.interfaces.AccessChecker}.
+ */
+@WebServlet("/myPatients")
+public class CustomFhirEndpointExample extends HttpServlet {
+
+ private static final Logger logger = LoggerFactory.getLogger(CustomFhirEndpointExample.class);
+ private final TokenVerifier tokenVerifier;
+
+ private final HttpFhirClient fhirClient;
+
+ public CustomFhirEndpointExample() throws IOException {
+ this.tokenVerifier = TokenVerifier.createFromEnvVars();
+ this.fhirClient = FhirClientFactory.createFhirClientFromEnvVars();
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ // Check the Bearer token to be a valid JWT with required claims.
+ String authHeader = request.getHeader("Authorization");
+ if (authHeader == null) {
+ throw new ServletException("No Authorization header provided!");
+ }
+ List patientIds = new ArrayList<>();
+ // Note for a more meaningful HTTP status code, we can catch AuthenticationException in:
+ DecodedJWT jwt = tokenVerifier.decodeAndVerifyBearerToken(authHeader);
+ Claim claim = jwt.getClaim("patient_list");
+ if (claim.asString() != null) {
+ logger.info("Found a 'patient_list' claim: {}", claim);
+ String listUri = "List/" + claim.asString();
+ HttpResponse fhirResponse = fhirClient.getResource(listUri);
+ HttpUtil.validateResponseOrFail(fhirResponse, listUri);
+ if (fhirResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
+ logger.error("Error while fetching {}", listUri);
+ response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+ return;
+ }
+ FhirContext fhirContext = FhirContext.forCached(FhirVersionEnum.R4);
+ IParser jsonParser = fhirContext.newJsonParser();
+ IBaseResource resource = jsonParser.parseResource(fhirResponse.getEntity().getContent());
+ ListResource listResource = (ListResource) resource;
+ for (ListEntryComponent entry : listResource.getEntry()) {
+ patientIds.add(entry.getItem().getReference());
+ }
+ } else {
+ claim = jwt.getClaim("patient_id");
+ if (claim.asString() != null) {
+ logger.info("Found a 'patient_id' claim: {}", claim);
+ patientIds.add(claim.asString());
+ }
+ }
+ if (claim.asString() == null) {
+ String error = "Found no patient claim in the token!";
+ logger.error(error);
+ response.getOutputStream().print(error);
+ response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR);
+ return;
+ }
+ response.getOutputStream().print("Your patient are: " + String.join(" ", patientIds));
+ response.setStatus(HttpStatus.SC_OK);
+ }
+}
diff --git a/exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java b/exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java
new file mode 100644
index 00000000..a8a34954
--- /dev/null
+++ b/exec/src/main/java/com/google/fhir/gateway/CustomGenericEndpointExample.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021-2023 Google LLC
+ *
+ * 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.google.fhir.gateway;
+
+import java.io.IOException;
+import javax.servlet.annotation.WebServlet;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.HttpStatus;
+
+/**
+ * This is an example servlet that can be used for any custom endpoint. It does not make any
+ * assumptions about authorization headers or accessing a FHIR server.
+ */
+@WebServlet("/custom/*")
+public class CustomGenericEndpointExample extends HttpServlet {
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse resp) throws IOException {
+ String uri = request.getRequestURI();
+ // For a real production case, `uri` needs to be escaped.
+ resp.getOutputStream().print("Successful request to the custom endpoint " + uri);
+ resp.setStatus(HttpStatus.SC_OK);
+ }
+}
diff --git a/exec/src/main/java/com/google/fhir/gateway/MainApp.java b/exec/src/main/java/com/google/fhir/gateway/MainApp.java
index 815e3976..bd2b9bff 100644
--- a/exec/src/main/java/com/google/fhir/gateway/MainApp.java
+++ b/exec/src/main/java/com/google/fhir/gateway/MainApp.java
@@ -19,6 +19,10 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
+/**
+ * This class shows the minimum that is required to create a FHIR Gateway with all AccessChecker
+ * plugins defined in "com.google.fhir.gateway.plugin".
+ */
@SpringBootApplication(scanBasePackages = {"com.google.fhir.gateway.plugin"})
@ServletComponentScan(basePackages = "com.google.fhir.gateway")
public class MainApp {
diff --git a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java
index d18496a6..f971ec55 100644
--- a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java
+++ b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java
@@ -26,13 +26,7 @@
import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
-import com.auth0.jwt.JWT;
-import com.auth0.jwt.JWTVerifier;
-import com.auth0.jwt.algorithms.Algorithm;
-import com.auth0.jwt.exceptions.JWTDecodeException;
-import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
-import com.auth0.jwt.interfaces.Verification;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.fhir.gateway.interfaces.AccessChecker;
@@ -40,27 +34,14 @@
import com.google.fhir.gateway.interfaces.AccessDecision;
import com.google.fhir.gateway.interfaces.RequestDetailsReader;
import com.google.fhir.gateway.interfaces.RequestMutation;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.Writer;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.nio.charset.StandardCharsets;
-import java.security.KeyFactory;
-import java.security.NoSuchAlgorithmException;
-import java.security.interfaces.RSAPublicKey;
-import java.security.spec.EncodedKeySpec;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.X509EncodedKeySpec;
-import java.util.Base64;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
-import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;
@@ -72,7 +53,6 @@ public class BearerAuthorizationInterceptor {
LoggerFactory.getLogger(BearerAuthorizationInterceptor.class);
private static final String DEFAULT_CONTENT_TYPE = "application/json; charset=UTF-8";
- private static final String BEARER_PREFIX = "Bearer ";
private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding";
@@ -84,25 +64,16 @@ public class BearerAuthorizationInterceptor {
// For fetching CapabilityStatement: https://www.hl7.org/fhir/http.html#capabilities
@VisibleForTesting static final String METADATA_PATH = "metadata";
- // TODO: Make this configurable or based on the given JWT; we should at least support some other
- // RSA* and ES* algorithms (requires ECDSA512 JWT algorithm).
- private static final String SIGN_ALGORITHM = "RS256";
-
- private final String tokenIssuer;
- private final Verification jwtVerifierConfig;
- private final HttpUtil httpUtil;
+ private final TokenVerifier tokenVerifier;
private final RestfulServer server;
private final HttpFhirClient fhirClient;
private final AccessCheckerFactory accessFactory;
private final AllowedQueriesChecker allowedQueriesChecker;
- private final String configJson;
BearerAuthorizationInterceptor(
HttpFhirClient fhirClient,
- String tokenIssuer,
- String wellKnownEndpoint,
+ TokenVerifier tokenVerifier,
RestfulServer server,
- HttpUtil httpUtil,
AccessCheckerFactory accessFactory,
AllowedQueriesChecker allowedQueriesChecker)
throws IOException {
@@ -110,113 +81,12 @@ public class BearerAuthorizationInterceptor {
Preconditions.checkNotNull(server);
this.server = server;
this.fhirClient = fhirClient;
- this.httpUtil = httpUtil;
- this.tokenIssuer = tokenIssuer;
+ this.tokenVerifier = tokenVerifier;
this.accessFactory = accessFactory;
this.allowedQueriesChecker = allowedQueriesChecker;
- RSAPublicKey issuerPublicKey = fetchAndDecodePublicKey();
- jwtVerifierConfig = JWT.require(Algorithm.RSA256(issuerPublicKey, null));
- this.configJson = httpUtil.fetchWellKnownConfig(tokenIssuer, wellKnownEndpoint);
logger.info("Created proxy to the FHIR store " + this.fhirClient.getBaseUrl());
}
- private RSAPublicKey fetchAndDecodePublicKey() throws IOException {
- // Preconditions.checkState(SIGN_ALGORITHM.equals("ES512"));
- Preconditions.checkState(SIGN_ALGORITHM.equals("RS256"));
- // final String keyAlgorithm = "EC";
- final String keyAlgorithm = "RSA";
- try {
- // TODO: Make sure this works for any issuer not just Keycloak; instead of this we should
- // read the metadata and choose the right endpoint for the keys.
- HttpResponse response = httpUtil.getResourceOrFail(new URI(tokenIssuer));
- JsonObject jsonObject =
- JsonParser.parseString(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))
- .getAsJsonObject();
- String keyStr = jsonObject.get("public_key").getAsString();
- if (keyStr == null) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger, "Cannot find 'public_key' in issuer metadata.");
- }
- KeyFactory keyFactory = KeyFactory.getInstance(keyAlgorithm);
- EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(keyStr));
- return (RSAPublicKey) keyFactory.generatePublic(keySpec);
- } catch (URISyntaxException e) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger, "Error in token issuer URI " + tokenIssuer, e, AuthenticationException.class);
- } catch (NoSuchAlgorithmException e) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger, "Invalid algorithm " + keyAlgorithm, e, AuthenticationException.class);
- } catch (InvalidKeySpecException e) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger, "Invalid KeySpec: " + e.getMessage(), e, AuthenticationException.class);
- }
- // We should never get here, this is to keep the IDE happy!
- return null;
- }
-
- private JWTVerifier buildJwtVerifier(String issuer) {
-
- if (tokenIssuer.equals(issuer)) {
- return jwtVerifierConfig.withIssuer(tokenIssuer).build();
- } else if (FhirProxyServer.isDevMode()) {
- // If server is in DEV mode, set issuer to one from request
- logger.warn("Server run in DEV mode. Setting issuer to issuer from request.");
- return jwtVerifierConfig.withIssuer(issuer).build();
- } else {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger,
- String.format("The token issuer %s does not match the expected token issuer", issuer),
- AuthenticationException.class);
- return null;
- }
- }
-
- @VisibleForTesting
- DecodedJWT decodeAndVerifyBearerToken(String authHeader) {
- if (!authHeader.startsWith(BEARER_PREFIX)) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger,
- "Authorization header is not a valid Bearer token!",
- AuthenticationException.class);
- }
- String bearerToken = authHeader.substring(BEARER_PREFIX.length());
- DecodedJWT jwt = null;
- try {
- jwt = JWT.decode(bearerToken);
- } catch (JWTDecodeException e) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger, "Failed to decode JWT: " + e.getMessage(), e, AuthenticationException.class);
- }
- String issuer = jwt.getIssuer();
- String algorithm = jwt.getAlgorithm();
- JWTVerifier jwtVerifier = buildJwtVerifier(issuer);
- logger.info(
- String.format(
- "JWT issuer is %s, audience is %s, and algorithm is %s",
- issuer, jwt.getAudience(), algorithm));
-
- if (!SIGN_ALGORITHM.equals(algorithm)) {
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger,
- String.format(
- "Only %s signing algorithm is supported, got %s", SIGN_ALGORITHM, algorithm),
- AuthenticationException.class);
- }
- DecodedJWT verifiedJwt = null;
- try {
- verifiedJwt = jwtVerifier.verify(jwt);
- } catch (JWTVerificationException e) {
- // Throwing an AuthenticationException instead since it is handled by HAPI and a 401
- // status code is returned in the response.
- ExceptionUtil.throwRuntimeExceptionAndLog(
- logger,
- String.format("JWT verification failed with error: %s", e.getMessage()),
- e,
- AuthenticationException.class);
- }
- return verifiedJwt;
- }
-
private AccessDecision checkAuthorization(RequestDetails requestDetails) {
if (METADATA_PATH.equals(requestDetails.getRequestPath())) {
// No further check is required; provide CapabilityStatement with security information.
@@ -236,7 +106,7 @@ private AccessDecision checkAuthorization(RequestDetails requestDetails) {
ExceptionUtil.throwRuntimeExceptionAndLog(
logger, "No Authorization header provided!", AuthenticationException.class);
}
- DecodedJWT decodedJwt = decodeAndVerifyBearerToken(authHeader);
+ DecodedJWT decodedJwt = tokenVerifier.decodeAndVerifyBearerToken(authHeader);
FhirContext fhirContext = server.getFhirContext();
AccessDecision allowedQueriesDecision = allowedQueriesChecker.checkAccess(requestDetailsReader);
if (allowedQueriesDecision.canAccess()) {
@@ -399,7 +269,7 @@ private void serveWellKnown(ServletRequestDetails request) {
DEFAULT_CONTENT_TYPE,
Constants.CHARSET_NAME_UTF8,
false);
- writer.write(configJson);
+ writer.write(tokenVerifier.getWellKnownConfig());
writer.close();
} catch (IOException e) {
logger.error(
diff --git a/server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java b/server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java
new file mode 100644
index 00000000..45a98891
--- /dev/null
+++ b/server/src/main/java/com/google/fhir/gateway/FhirClientFactory.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2021-2023 Google LLC
+ *
+ * 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.google.fhir.gateway;
+
+import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder;
+import java.io.IOException;
+
+/**
+ * This is a helper class to create appropriate FHIR clients to talk to the configured FHIR server.
+ */
+public class FhirClientFactory {
+ private static final String PROXY_TO_ENV = "PROXY_TO";
+ private static final String BACKEND_TYPE_ENV = "BACKEND_TYPE";
+
+ public static HttpFhirClient createFhirClientFromEnvVars() throws IOException {
+ String backendType = System.getenv(BACKEND_TYPE_ENV);
+ if (backendType == null) {
+ throw new IllegalArgumentException(
+ String.format("The environment variable %s is not set!", BACKEND_TYPE_ENV));
+ }
+ String fhirStore = System.getenv(PROXY_TO_ENV);
+ if (fhirStore == null) {
+ throw new IllegalArgumentException(
+ String.format("The environment variable %s is not set!", PROXY_TO_ENV));
+ }
+ return chooseHttpFhirClient(backendType, fhirStore);
+ }
+
+ private static HttpFhirClient chooseHttpFhirClient(String backendType, String fhirStore)
+ throws IOException {
+ // TODO add an enum if the list of special FHIR servers grow and rename HAPI to GENERIC.
+ if (backendType.equals("GCP")) {
+ return new GcpFhirClient(fhirStore, GcpFhirClient.createCredentials());
+ }
+
+ if (backendType.equals("HAPI")) {
+ return new GenericFhirClientBuilder().setFhirStore(fhirStore).build();
+ }
+ throw new IllegalArgumentException(
+ String.format(
+ "The environment variable %s is not set to either GCP or HAPI!", BACKEND_TYPE_ENV));
+ }
+}
diff --git a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java
index ec180b63..bf44d67d 100644
--- a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java
+++ b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java
@@ -19,7 +19,6 @@
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.fhir.rest.server.interceptor.CorsInterceptor;
-import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder;
import com.google.fhir.gateway.interfaces.AccessCheckerFactory;
import java.io.IOException;
import java.util.ArrayList;
@@ -32,18 +31,13 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.cors.CorsConfiguration;
-@WebServlet("/*")
+@WebServlet("/fhir/*")
public class FhirProxyServer extends RestfulServer {
private static final Logger logger = LoggerFactory.getLogger(FhirProxyServer.class);
- private static final String PROXY_TO_ENV = "PROXY_TO";
- private static final String TOKEN_ISSUER_ENV = "TOKEN_ISSUER";
private static final String ACCESS_CHECKER_ENV = "ACCESS_CHECKER";
private static final String PERMISSIVE_ACCESS_CHECKER = "permissive";
- private static final String BACKEND_TYPE_ENV = "BACKEND_TYPE";
- private static final String WELL_KNOWN_ENDPOINT_ENV = "WELL_KNOWN_ENDPOINT";
- private static final String WELL_KNOWN_ENDPOINT_DEFAULT = ".well-known/openid-configuration";
private static final String ALLOWED_QUERIES_FILE_ENV = "ALLOWED_QUERIES_FILE";
// TODO: improve this mixture of Spring based IOC with non-@Component classes. This is the
@@ -61,30 +55,6 @@ static boolean isDevMode() {
// implement a way to kill the server immediately when initialize fails.
@Override
protected void initialize() throws ServletException {
- String backendType = System.getenv(BACKEND_TYPE_ENV);
- if (backendType == null) {
- throw new ServletException(
- String.format("The environment variable %s is not set!", BACKEND_TYPE_ENV));
- }
- String fhirStore = System.getenv(PROXY_TO_ENV);
- if (fhirStore == null) {
- throw new ServletException(
- String.format("The environment variable %s is not set!", PROXY_TO_ENV));
- }
- String tokenIssuer = System.getenv(TOKEN_ISSUER_ENV);
- if (tokenIssuer == null) {
- throw new ServletException(
- String.format("The environment variable %s is not set!", TOKEN_ISSUER_ENV));
- }
-
- String wellKnownEndpoint = System.getenv(WELL_KNOWN_ENDPOINT_ENV);
- if (wellKnownEndpoint == null) {
- wellKnownEndpoint = WELL_KNOWN_ENDPOINT_DEFAULT;
- logger.info(
- String.format(
- "The environment variable %s is not set! Using default value of %s instead ",
- WELL_KNOWN_ENDPOINT_ENV, WELL_KNOWN_ENDPOINT_DEFAULT));
- }
// TODO make the FHIR version configurable.
// Create a context for the appropriate version
setFhirContext(FhirContext.forR4());
@@ -95,14 +65,13 @@ protected void initialize() throws ServletException {
try {
logger.info("Adding BearerAuthorizationInterceptor ");
AccessCheckerFactory checkerFactory = chooseAccessCheckerFactory();
- HttpFhirClient httpFhirClient = chooseHttpFhirClient(backendType, fhirStore);
+ HttpFhirClient httpFhirClient = FhirClientFactory.createFhirClientFromEnvVars();
+ TokenVerifier tokenVerifier = TokenVerifier.createFromEnvVars();
registerInterceptor(
new BearerAuthorizationInterceptor(
httpFhirClient,
- tokenIssuer,
- wellKnownEndpoint,
+ tokenVerifier,
this,
- new HttpUtil(),
checkerFactory,
new AllowedQueriesChecker(System.getenv(ALLOWED_QUERIES_FILE_ENV))));
} catch (IOException e) {
@@ -110,20 +79,6 @@ protected void initialize() throws ServletException {
}
}
- private HttpFhirClient chooseHttpFhirClient(String backendType, String fhirStore)
- throws ServletException, IOException {
- if (backendType.equals("GCP")) {
- return new GcpFhirClient(fhirStore, GcpFhirClient.createCredentials());
- }
-
- if (backendType.equals("HAPI")) {
- return new GenericFhirClientBuilder().setFhirStore(fhirStore).build();
- }
- throw new ServletException(
- String.format(
- "The environment variable %s is not set to either GCP or HAPI!", BACKEND_TYPE_ENV));
- }
-
private AccessCheckerFactory chooseAccessCheckerFactory() {
logger.info(
String.format(
diff --git a/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java b/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java
index 1765d48a..ee9071d7 100644
--- a/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java
+++ b/server/src/main/java/com/google/fhir/gateway/GenericFhirClient.java
@@ -17,7 +17,6 @@
import java.net.URI;
import java.net.URISyntaxException;
-import javax.servlet.ServletException;
import org.apache.http.Header;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.message.BasicHeader;
@@ -61,9 +60,9 @@ public GenericFhirClientBuilder setFhirStore(String fhirStore) {
return this;
}
- public GenericFhirClient build() throws ServletException {
+ public GenericFhirClient build() {
if (fhirStore == null || fhirStore.isBlank()) {
- throw new ServletException("FhirStore not set!");
+ throw new IllegalArgumentException("FhirStore not set!");
}
return new GenericFhirClient(fhirStore);
}
diff --git a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java
index 0570bdf8..d9e46167 100644
--- a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java
+++ b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java
@@ -37,6 +37,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+// TODO evaluate if we can provide the API of HAPI's IGenericClient as well:
+// https://hapifhir.io/hapi-fhir/docs/client/generic_client.html
public abstract class HttpFhirClient {
private static final Logger logger = LoggerFactory.getLogger(HttpFhirClient.class);
@@ -102,6 +104,7 @@ private void setUri(RequestBuilder builder, String resourcePath) {
}
}
+ /** This method is intended to be used only for requests that are relayed to the FHIR store. */
HttpResponse handleRequest(ServletRequestDetails request) throws IOException {
String httpMethod = request.getServletRequest().getMethod();
RequestBuilder builder = RequestBuilder.create(httpMethod);
diff --git a/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java b/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java
new file mode 100644
index 00000000..c88e6a30
--- /dev/null
+++ b/server/src/main/java/com/google/fhir/gateway/TokenVerifier.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2021-2023 Google LLC
+ *
+ * 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.google.fhir.gateway;
+
+import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.exceptions.JWTDecodeException;
+import com.auth0.jwt.exceptions.JWTVerificationException;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.auth0.jwt.interfaces.Verification;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.EncodedKeySpec;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import org.apache.http.HttpResponse;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class TokenVerifier {
+
+ private static final Logger logger = LoggerFactory.getLogger(TokenVerifier.class);
+ private static final String TOKEN_ISSUER_ENV = "TOKEN_ISSUER";
+ private static final String WELL_KNOWN_ENDPOINT_ENV = "WELL_KNOWN_ENDPOINT";
+ private static final String WELL_KNOWN_ENDPOINT_DEFAULT = ".well-known/openid-configuration";
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ // TODO: Make this configurable or based on the given JWT; we should at least support some other
+ // RSA* and ES* algorithms (requires ECDSA512 JWT algorithm).
+ private static final String SIGN_ALGORITHM = "RS256";
+
+ private final String tokenIssuer;
+ private final Verification jwtVerifierConfig;
+ private final HttpUtil httpUtil;
+ private final String configJson;
+
+ @VisibleForTesting
+ TokenVerifier(String tokenIssuer, String wellKnownEndpoint, HttpUtil httpUtil)
+ throws IOException {
+ this.tokenIssuer = tokenIssuer;
+ this.httpUtil = httpUtil;
+ RSAPublicKey issuerPublicKey = fetchAndDecodePublicKey();
+ jwtVerifierConfig = JWT.require(Algorithm.RSA256(issuerPublicKey, null));
+ this.configJson = httpUtil.fetchWellKnownConfig(tokenIssuer, wellKnownEndpoint);
+ }
+
+ public static TokenVerifier createFromEnvVars() throws IOException {
+ String tokenIssuer = System.getenv(TOKEN_ISSUER_ENV);
+ if (tokenIssuer == null) {
+ throw new IllegalArgumentException(
+ String.format("The environment variable %s is not set!", TOKEN_ISSUER_ENV));
+ }
+
+ String wellKnownEndpoint = System.getenv(WELL_KNOWN_ENDPOINT_ENV);
+ if (wellKnownEndpoint == null) {
+ wellKnownEndpoint = WELL_KNOWN_ENDPOINT_DEFAULT;
+ logger.info(
+ String.format(
+ "The environment variable %s is not set! Using default value of %s instead ",
+ WELL_KNOWN_ENDPOINT_ENV, WELL_KNOWN_ENDPOINT_DEFAULT));
+ }
+ return new TokenVerifier(tokenIssuer, wellKnownEndpoint, new HttpUtil());
+ }
+
+ public String getWellKnownConfig() {
+ return configJson;
+ }
+
+ private RSAPublicKey fetchAndDecodePublicKey() throws IOException {
+ // Preconditions.checkState(SIGN_ALGORITHM.equals("ES512"));
+ Preconditions.checkState(SIGN_ALGORITHM.equals("RS256"));
+ // final String keyAlgorithm = "EC";
+ final String keyAlgorithm = "RSA";
+ try {
+ // TODO: Make sure this works for any issuer not just Keycloak; instead of this we should
+ // read the metadata and choose the right endpoint for the keys.
+ HttpResponse response = httpUtil.getResourceOrFail(new URI(tokenIssuer));
+ JsonObject jsonObject =
+ JsonParser.parseString(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8))
+ .getAsJsonObject();
+ String keyStr = jsonObject.get("public_key").getAsString();
+ if (keyStr == null) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger, "Cannot find 'public_key' in issuer metadata.");
+ }
+ KeyFactory keyFactory = KeyFactory.getInstance(keyAlgorithm);
+ EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(keyStr));
+ return (RSAPublicKey) keyFactory.generatePublic(keySpec);
+ } catch (URISyntaxException e) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger, "Error in token issuer URI " + tokenIssuer, e, AuthenticationException.class);
+ } catch (NoSuchAlgorithmException e) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger, "Invalid algorithm " + keyAlgorithm, e, AuthenticationException.class);
+ } catch (InvalidKeySpecException e) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger, "Invalid KeySpec: " + e.getMessage(), e, AuthenticationException.class);
+ }
+ // We should never get here, this is to keep the IDE happy!
+ return null;
+ }
+
+ private JWTVerifier buildJwtVerifier(String issuer) {
+
+ if (tokenIssuer.equals(issuer)) {
+ return jwtVerifierConfig.withIssuer(tokenIssuer).build();
+ } else if (FhirProxyServer.isDevMode()) {
+ // If server is in DEV mode, set issuer to one from request
+ logger.warn("Server run in DEV mode. Setting issuer to issuer from request.");
+ return jwtVerifierConfig.withIssuer(issuer).build();
+ } else {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger,
+ String.format("The token issuer %s does not match the expected token issuer", issuer),
+ AuthenticationException.class);
+ return null;
+ }
+ }
+
+ @VisibleForTesting
+ DecodedJWT decodeAndVerifyBearerToken(String authHeader) {
+ if (!authHeader.startsWith(BEARER_PREFIX)) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger,
+ "Authorization header is not a valid Bearer token!",
+ AuthenticationException.class);
+ }
+ String bearerToken = authHeader.substring(BEARER_PREFIX.length());
+ DecodedJWT jwt = null;
+ try {
+ jwt = JWT.decode(bearerToken);
+ } catch (JWTDecodeException e) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger, "Failed to decode JWT: " + e.getMessage(), e, AuthenticationException.class);
+ }
+ String issuer = jwt.getIssuer();
+ String algorithm = jwt.getAlgorithm();
+ JWTVerifier jwtVerifier = buildJwtVerifier(issuer);
+ logger.info(
+ String.format(
+ "JWT issuer is %s, audience is %s, and algorithm is %s",
+ issuer, jwt.getAudience(), algorithm));
+
+ if (!SIGN_ALGORITHM.equals(algorithm)) {
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger,
+ String.format(
+ "Only %s signing algorithm is supported, got %s", SIGN_ALGORITHM, algorithm),
+ AuthenticationException.class);
+ }
+ DecodedJWT verifiedJwt = null;
+ try {
+ verifiedJwt = jwtVerifier.verify(jwt);
+ } catch (JWTVerificationException e) {
+ // Throwing an AuthenticationException instead since it is handled by HAPI and a 401
+ // status code is returned in the response.
+ ExceptionUtil.throwRuntimeExceptionAndLog(
+ logger,
+ String.format("JWT verification failed with error: %s", e.getMessage()),
+ e,
+ AuthenticationException.class);
+ }
+ return verifiedJwt;
+ }
+}
diff --git a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java
index d2e5672a..466ef318 100644
--- a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java
+++ b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java
@@ -29,14 +29,9 @@
import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.api.server.IRestfulResponse;
import ca.uhn.fhir.rest.server.RestfulServer;
-import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.rest.server.servlet.ServletRestfulResponse;
-import com.auth0.jwt.JWT;
-import com.auth0.jwt.JWTCreator;
-import com.auth0.jwt.algorithms.Algorithm;
-import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.io.Resources;
@@ -49,16 +44,8 @@
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
-import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
-import java.security.GeneralSecurityException;
-import java.security.Key;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.interfaces.RSAPrivateKey;
-import java.security.interfaces.RSAPublicKey;
-import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -89,19 +76,15 @@ public class BearerAuthorizationInterceptorTest {
private BearerAuthorizationInterceptor testInstance;
- private static final String BASE_URL = "http://myprxy/fhir";
+ private static final String BASE_URL = "http://myproxy/fhir";
private static final String FHIR_STORE =
"https://healthcare.googleapis.com/v1/projects/fhir-sdk/locations/us/datasets/"
+ "synthea-sample-data/fhirStores/gcs-data/fhir";
- private static final String TOKEN_ISSUER = "https://token.issuer";
-
- private KeyPair keyPair;
-
@Mock private HttpFhirClient fhirClientMock;
@Mock private RestfulServer serverMock;
- @Mock private HttpUtil httpUtilMock;
+ @Mock private TokenVerifier tokenVerifierMock;
@Mock private ServletRequestDetails requestMock;
@@ -110,29 +93,12 @@ public class BearerAuthorizationInterceptorTest {
private final Writer writerStub = new StringWriter();
- private String generateKeyPairAndEncode() {
- try {
- KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
- generator.initialize(1024);
- keyPair = generator.generateKeyPair();
- Key publicKey = keyPair.getPublic();
- Preconditions.checkState("X.509".equals(publicKey.getFormat()));
- return Base64.getEncoder().encodeToString(publicKey.getEncoded());
- } catch (GeneralSecurityException e) {
- logger.error("error in generating keys", e);
- Preconditions.checkState(false); // We should never get here!
- }
- return null;
- }
-
private BearerAuthorizationInterceptor createTestInstance(
boolean isAccessGranted, String allowedQueriesConfig) throws IOException {
return new BearerAuthorizationInterceptor(
fhirClientMock,
- TOKEN_ISSUER,
- "test",
+ tokenVerifierMock,
serverMock,
- httpUtilMock,
(jwt, httpFhirClient, fhirContext, patientFinder) ->
new AccessChecker() {
@Override
@@ -145,75 +111,21 @@ public AccessDecision checkAccess(RequestDetailsReader requestDetails) {
@Before
public void setUp() throws IOException {
- String publicKeyBase64 = generateKeyPairAndEncode();
- HttpResponse responseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS);
when(serverMock.getServerBaseForRequest(any(ServletRequestDetails.class))).thenReturn(BASE_URL);
when(serverMock.getFhirContext()).thenReturn(fhirContext);
- when(httpUtilMock.getResourceOrFail(any(URI.class))).thenReturn(responseMock);
- TestUtil.setUpFhirResponseMock(
- responseMock, String.format("{public_key: '%s'}", publicKeyBase64));
- URL idpUrl = Resources.getResource("idp_keycloak_config.json");
- String testIdpConfig = Resources.toString(idpUrl, StandardCharsets.UTF_8);
- when(httpUtilMock.fetchWellKnownConfig(anyString(), anyString())).thenReturn(testIdpConfig);
when(fhirClientMock.handleRequest(requestMock)).thenReturn(fhirResponseMock);
when(fhirClientMock.getBaseUrl()).thenReturn(FHIR_STORE);
testInstance = createTestInstance(true, null);
}
- private String signJwt(JWTCreator.Builder jwtBuilder) {
- Algorithm algorithm =
- Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate());
- String token = jwtBuilder.sign(algorithm);
- logger.debug(String.format(" The generated JWT is: %s", token));
- return token;
- }
-
- @Test
- public void decodeAndVerifyBearerTokenTest() {
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder));
- }
-
- @Test(expected = AuthenticationException.class)
- public void decodeAndVerifyBearerTokenWrongIssuer() {
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER + "WRONG");
- testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder));
- }
-
- @Test(expected = AuthenticationException.class)
- public void decodeAndVerifyBearerTokenBadSignature() {
- // We overwrite the original `keyPair` hence the signature won't match the original public key.
- generateKeyPairAndEncode();
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder));
- }
-
- @Test(expected = AuthenticationException.class)
- public void decodeAndVerifyBearerTokenNoBearer() {
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- testInstance.decodeAndVerifyBearerToken(signJwt(jwtBuilder));
- }
-
- @Test(expected = AuthenticationException.class)
- public void decodeAndVerifyBearerTokenMalformedBearer() {
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- testInstance.decodeAndVerifyBearerToken("BearerTTT " + signJwt(jwtBuilder));
- }
-
- @Test(expected = AuthenticationException.class)
- public void decodeAndVerifyBearerTokenMalformedToken() {
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- testInstance.decodeAndVerifyBearerToken("Bearer TTT");
+ private void setupBearerAndFhirResponse(String fhirStoreResponse) throws IOException {
+ setupFhirResponse(fhirStoreResponse, true);
}
- private void authorizeRequestCommonSetUp(String fhirStoreResponse) throws IOException {
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- String jwt = signJwt(jwtBuilder);
- when(requestMock.getHeader("Authorization")).thenReturn("Bearer " + jwt);
- setupFhirResponse(fhirStoreResponse);
- }
-
- private void setupFhirResponse(String fhirStoreResponse) throws IOException {
+ private void setupFhirResponse(String fhirStoreResponse, boolean addBearer) throws IOException {
+ if (addBearer) {
+ when(requestMock.getHeader("Authorization")).thenReturn("Bearer ANYTHING");
+ }
IRestfulResponse proxyResponseMock = Mockito.mock(IRestfulResponse.class);
when(requestMock.getResponse()).thenReturn(proxyResponseMock);
when(proxyResponseMock.getResponseWriter(
@@ -226,7 +138,7 @@ private void setupFhirResponse(String fhirStoreResponse) throws IOException {
public void authorizeRequestPatient() throws IOException {
URL patientUrl = Resources.getResource("test_patient.json");
String testPatientJson = Resources.toString(patientUrl, StandardCharsets.UTF_8);
- authorizeRequestCommonSetUp(testPatientJson);
+ setupBearerAndFhirResponse(testPatientJson);
testInstance.authorizeRequest(requestMock);
assertThat(testPatientJson, equalTo(writerStub.toString()));
}
@@ -235,7 +147,7 @@ public void authorizeRequestPatient() throws IOException {
public void authorizeRequestList() throws IOException {
URL patientUrl = Resources.getResource("patient-list-example.json");
String testListJson = Resources.toString(patientUrl, StandardCharsets.UTF_8);
- authorizeRequestCommonSetUp(testListJson);
+ setupBearerAndFhirResponse(testListJson);
testInstance.authorizeRequest(requestMock);
assertThat(testListJson, equalTo(writerStub.toString()));
}
@@ -244,7 +156,7 @@ public void authorizeRequestList() throws IOException {
public void authorizeRequestTestReplaceUrl() throws IOException {
URL searchUrl = Resources.getResource("patient_id_search.json");
String testPatientIdSearch = Resources.toString(searchUrl, StandardCharsets.UTF_8);
- authorizeRequestCommonSetUp(testPatientIdSearch);
+ setupBearerAndFhirResponse(testPatientIdSearch);
testInstance.authorizeRequest(requestMock);
String replaced = testPatientIdSearch.replaceAll(FHIR_STORE, BASE_URL);
assertThat(replaced, equalTo(writerStub.toString()));
@@ -254,7 +166,7 @@ public void authorizeRequestTestReplaceUrl() throws IOException {
public void authorizeRequestTestResourceErrorResponse() throws IOException {
URL errorUrl = Resources.getResource("error_operation_outcome.json");
String errorResponse = Resources.toString(errorUrl, StandardCharsets.UTF_8);
- authorizeRequestCommonSetUp(errorResponse);
+ setupBearerAndFhirResponse(errorResponse);
when(fhirResponseMock.getStatusLine().getStatusCode())
.thenReturn(HttpStatus.SC_INTERNAL_SERVER_ERROR);
testInstance.authorizeRequest(requestMock);
@@ -277,6 +189,10 @@ public void authorizeRequestWellKnown() throws IOException {
HttpServletRequest servletRequestMock = Mockito.mock(HttpServletRequest.class);
when(requestMock.getServletRequest()).thenReturn(servletRequestMock);
when(servletRequestMock.getProtocol()).thenReturn("HTTP/1.1");
+ URL idpUrl = Resources.getResource("idp_keycloak_config.json");
+ String testIdpConfig = Resources.toString(idpUrl, StandardCharsets.UTF_8);
+ when(tokenVerifierMock.getWellKnownConfig()).thenReturn(testIdpConfig);
+
testInstance.authorizeRequest(requestMock);
Gson gson = new Gson();
Map jsonMap = Maps.newHashMap();
@@ -321,7 +237,7 @@ public void authorizeRequestMetadata() throws IOException {
noAuthRequestSetup(BearerAuthorizationInterceptor.METADATA_PATH);
URL capabilityUrl = Resources.getResource("capability.json");
String capabilityJson = Resources.toString(capabilityUrl, StandardCharsets.UTF_8);
- authorizeRequestCommonSetUp(capabilityJson);
+ setupBearerAndFhirResponse(capabilityJson);
testInstance.authorizeRequest(requestMock);
IParser parser = fhirContext.newJsonParser();
IBaseResource resource = parser.parseResource(writerStub.toString());
@@ -340,7 +256,7 @@ public void authorizeAllowedUnauthenticatedRequest() throws IOException {
createTestInstance(
false, Resources.getResource("allowed_unauthenticated_queries.json").getPath());
String responseJson = "{\"resourceType\": \"Bundle\"}";
- setupFhirResponse(responseJson);
+ setupFhirResponse(responseJson, false);
when(requestMock.getRequestPath()).thenReturn("Composition");
testInstance.authorizeRequest(requestMock);
@@ -354,7 +270,7 @@ public void deniedRequest() throws IOException {
testInstance =
createTestInstance(
false, Resources.getResource("allowed_unauthenticated_queries.json").getPath());
- authorizeRequestCommonSetUp("never returned response");
+ setupBearerAndFhirResponse("never returned response");
when(requestMock.getRequestPath()).thenReturn("Patient");
testInstance.authorizeRequest(requestMock);
@@ -400,8 +316,7 @@ public String postProcess(
public void shouldSendGzippedResponseWhenRequested() throws IOException {
testInstance = createTestInstance(true, null);
String responseJson = "{\"resourceType\": \"Bundle\"}";
- JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
- when(requestMock.getHeader("Authorization")).thenReturn("Bearer " + signJwt(jwtBuilder));
+ when(requestMock.getHeader("Authorization")).thenReturn("Bearer ANYTHING");
when(requestMock.getHeader("Accept-Encoding".toLowerCase())).thenReturn("gzip");
// requestMock.getResponse() {@link ServletRequestDetails#getResponse()} is an abstraction HAPI
diff --git a/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java b/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java
index 6391a8c5..e1e456f6 100644
--- a/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java
+++ b/server/src/test/java/com/google/fhir/gateway/GenericFhirClientTest.java
@@ -21,7 +21,6 @@
import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder;
import java.net.URI;
import java.net.URISyntaxException;
-import javax.servlet.ServletException;
import org.apache.http.Header;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -30,18 +29,18 @@
@RunWith(MockitoJUnitRunner.class)
public class GenericFhirClientTest {
- @Test(expected = ServletException.class)
- public void buildGenericFhirClientFhirStoreNotSetTest() throws ServletException {
+ @Test(expected = IllegalArgumentException.class)
+ public void buildGenericFhirClientFhirStoreNotSetTest() {
new GenericFhirClientBuilder().build();
}
- @Test(expected = ServletException.class)
- public void buildGenericFhirClientNoFhirStoreBlankTest() throws ServletException {
+ @Test(expected = IllegalArgumentException.class)
+ public void buildGenericFhirClientNoFhirStoreBlankTest() {
new GenericFhirClientBuilder().setFhirStore(" ").build();
}
@Test
- public void getAuthHeaderNoUsernamePasswordTest() throws ServletException {
+ public void getAuthHeaderNoUsernamePasswordTest() {
GenericFhirClient genericFhirClient =
new GenericFhirClientBuilder().setFhirStore("random.fhir").build();
Header header = genericFhirClient.getAuthHeader();
@@ -50,7 +49,7 @@ public void getAuthHeaderNoUsernamePasswordTest() throws ServletException {
}
@Test
- public void getUriForResourceTest() throws URISyntaxException, ServletException {
+ public void getUriForResourceTest() throws URISyntaxException {
GenericFhirClient genericFhirClient =
new GenericFhirClientBuilder().setFhirStore("random.fhir").build();
URI uri = genericFhirClient.getUriForResource("hello/world");
diff --git a/server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java b/server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java
new file mode 100644
index 00000000..6a014616
--- /dev/null
+++ b/server/src/test/java/com/google/fhir/gateway/TokenVerifierTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2021-2023 Google LLC
+ *
+ * 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.google.fhir.gateway;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTCreator;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.google.common.base.Preconditions;
+import com.google.common.io.Resources;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Base64;
+import org.apache.http.HttpResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RunWith(MockitoJUnitRunner.class)
+public class TokenVerifierTest {
+
+ private static final Logger logger = LoggerFactory.getLogger(TokenVerifierTest.class);
+ private static final String TOKEN_ISSUER = "https://token.issuer";
+
+ @Mock private HttpUtil httpUtilMock;
+ private KeyPair keyPair;
+ private String testIdpConfig;
+ private TokenVerifier testInstance;
+
+ private String generateKeyPairAndEncode() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ keyPair = generator.generateKeyPair();
+ Key publicKey = keyPair.getPublic();
+ Preconditions.checkState("X.509".equals(publicKey.getFormat()));
+ return Base64.getEncoder().encodeToString(publicKey.getEncoded());
+ } catch (GeneralSecurityException e) {
+ logger.error("error in generating keys", e);
+ Preconditions.checkState(false); // We should never get here!
+ }
+ return null;
+ }
+
+ @Before
+ public void setUp() throws IOException {
+ String publicKeyBase64 = generateKeyPairAndEncode();
+ HttpResponse responseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS);
+ when(httpUtilMock.getResourceOrFail(any(URI.class))).thenReturn(responseMock);
+ TestUtil.setUpFhirResponseMock(
+ responseMock, String.format("{public_key: '%s'}", publicKeyBase64));
+ URL idpUrl = Resources.getResource("idp_keycloak_config.json");
+ testIdpConfig = Resources.toString(idpUrl, StandardCharsets.UTF_8);
+ when(httpUtilMock.fetchWellKnownConfig(anyString(), anyString())).thenReturn(testIdpConfig);
+ testInstance = new TokenVerifier(TOKEN_ISSUER, "test", httpUtilMock);
+ }
+
+ private String signJwt(JWTCreator.Builder jwtBuilder) {
+ Algorithm algorithm =
+ Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate());
+ String token = jwtBuilder.sign(algorithm);
+ logger.debug(String.format(" The generated JWT is: %s", token));
+ return token;
+ }
+
+ @Test
+ public void decodeAndVerifyBearerTokenTest() {
+ JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
+ testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder));
+ }
+
+ @Test(expected = AuthenticationException.class)
+ public void decodeAndVerifyBearerTokenWrongIssuer() {
+ JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER + "WRONG");
+ testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder));
+ }
+
+ @Test(expected = AuthenticationException.class)
+ public void decodeAndVerifyBearerTokenBadSignature() {
+ // We overwrite the original `keyPair` hence the signature won't match the original public key.
+ generateKeyPairAndEncode();
+ JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
+ testInstance.decodeAndVerifyBearerToken("Bearer " + signJwt(jwtBuilder));
+ }
+
+ @Test(expected = AuthenticationException.class)
+ public void decodeAndVerifyBearerTokenNoBearer() {
+ JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
+ testInstance.decodeAndVerifyBearerToken(signJwt(jwtBuilder));
+ }
+
+ @Test(expected = AuthenticationException.class)
+ public void decodeAndVerifyBearerTokenMalformedBearer() {
+ JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
+ testInstance.decodeAndVerifyBearerToken("BearerTTT " + signJwt(jwtBuilder));
+ }
+
+ @Test(expected = AuthenticationException.class)
+ public void decodeAndVerifyBearerTokenMalformedToken() {
+ JWTCreator.Builder jwtBuilder = JWT.create().withIssuer(TOKEN_ISSUER);
+ testInstance.decodeAndVerifyBearerToken("Bearer TTT");
+ }
+
+ @Test
+ public void getWellKnownConfigTest() {
+ String config = testInstance.getWellKnownConfig();
+ assertThat(config, equalTo(testIdpConfig));
+ }
+}