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)); + } +}