Skip to content

Commit

Permalink
refactorings for supporting custom endpoints with examples
Browse files Browse the repository at this point in the history
  • Loading branch information
bashir2 committed Aug 18, 2023
1 parent 72598a7 commit 71cef8d
Show file tree
Hide file tree
Showing 14 changed files with 593 additions and 301 deletions.
2 changes: 1 addition & 1 deletion e2e-test/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions exec/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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();
resp.getOutputStream().print("Successful request to the custom endpoint " + uri);

Check warning

Code scanning / CodeQL

Cross-site scripting Medium

Cross-site scripting vulnerability due to a
user-provided value
.
resp.setStatus(HttpStatus.SC_OK);
}
}
4 changes: 4 additions & 0 deletions exec/src/main/java/com/google/fhir/gateway/MainApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,41 +26,22 @@
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;
import com.google.fhir.gateway.interfaces.AccessCheckerFactory;
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;
Expand All @@ -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";

Expand All @@ -84,139 +64,29 @@ 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 {
Preconditions.checkNotNull(fhirClient);
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.
Expand All @@ -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()) {
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 71cef8d

Please sign in to comment.