Skip to content

Commit

Permalink
Initial support for UDAP registration and auth
Browse files Browse the repository at this point in the history
  • Loading branch information
bstewartlg committed Jan 11, 2024
1 parent 5761dd5 commit 136d88b
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 38 deletions.
17 changes: 17 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>jwks-rsa</artifactId>
<version>0.22.1</version>
</dependency>

<!-- Needed for Email subscriptions -->
<dependency>
Expand Down Expand Up @@ -322,6 +327,12 @@
<version>${spring_boot_version}</version>
</dependency>

<!-- <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<version>${spring_boot_version}</version>
</dependency> -->

<!-- https://mvnrepository.com/artifact/io.micrometer/micrometer-core -->
<dependency>
<groupId>io.micrometer</groupId>
Expand Down Expand Up @@ -368,6 +379,12 @@
<version>3.22.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>


</dependencies>

Expand Down
6 changes: 2 additions & 4 deletions src/main/java/ca/uhn/fhir/jpa/starter/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
import ca.uhn.fhir.jpa.starter.annotations.OnEitherVersion;
import ca.uhn.fhir.jpa.starter.common.FhirTesterConfig;
import ca.uhn.fhir.jpa.starter.identitymatching.DiscoveryInterceptor;
import ca.uhn.fhir.jpa.starter.identitymatching.UnHapiServlet;
import ca.uhn.fhir.jpa.starter.mdm.MdmConfig;
import ca.uhn.fhir.jpa.starter.operations.IdentityMatching;
import ca.uhn.fhir.jpa.starter.operations.models.CustomHapiProperties;
import ca.uhn.fhir.jpa.starter.resourceproviders.PatientMatchResourceProvider;
import ca.uhn.fhir.jpa.starter.security.IdentityMatchingAuthInterceptor;
import ca.uhn.fhir.jpa.starter.security.models.SecurityConfig;
import ca.uhn.fhir.jpa.subscription.channel.config.SubscriptionChannelConfig;
Expand Down Expand Up @@ -91,8 +89,8 @@ public ServletRegistrationBean hapiServletRegistration(RestfulServer restfulServ
restfulServer.registerProviders(identityMatcher);

//register FAST security interceptors
DiscoveryInterceptor securityDiscoveryInterceptor = new DiscoveryInterceptor();
IdentityMatchingAuthInterceptor authInterceptor = new IdentityMatchingAuthInterceptor(securityConfig.isEnableAuthentication(),
DiscoveryInterceptor securityDiscoveryInterceptor = new DiscoveryInterceptor(customHapiProperties, securityConfig);
IdentityMatchingAuthInterceptor authInterceptor = new IdentityMatchingAuthInterceptor(securityConfig.getEnableAuthentication(),
securityConfig.getIssuer(), securityConfig.getPublicKey(),
securityConfig.getIntrospectionUrl(), securityConfig.getClientId(), securityConfig.getClientSecret(),
securityConfig.getProtectedEndpoints(), securityConfig.getPublicEndpoints());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,54 @@
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.jpa.starter.operations.models.CustomHapiProperties;
import ca.uhn.fhir.jpa.starter.operations.models.DiscoveryObject;
import ca.uhn.fhir.jpa.starter.security.models.SecurityConfig;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.apache.commons.lang3.StringUtils;
import org.elasticsearch.core.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.ResourceUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.sql.Date;
import java.time.Instant;
import java.util.Base64;
import java.util.UUID;

@Interceptor
public class DiscoveryInterceptor
{
private final Logger _logger = LoggerFactory.getLogger(DiscoveryInterceptor.class);

private SecurityConfig securityConfig;
private CustomHapiProperties customHapiProperties;

public DiscoveryInterceptor(CustomHapiProperties customHapiProperties, SecurityConfig securityConfig) {
this.customHapiProperties = customHapiProperties;
this.securityConfig = securityConfig;
}

@Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_PROCESSED)
public boolean incomingRequestPreProcessed(HttpServletRequest theRequest, HttpServletResponse theResponse) throws IOException {
public boolean incomingRequestPreProcessed(HttpServletRequest theRequest, HttpServletResponse theResponse) throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException, InvalidKeySpecException, UnrecoverableKeyException {
// Check if the request is for /.well-known/udap
if (theRequest.getRequestURI().equals("/fhir/.well-known/udap")) {

Expand All @@ -29,6 +60,42 @@ public boolean incomingRequestPreProcessed(HttpServletRequest theRequest, HttpSe
File discoveryJson = ResourceUtils.getFile("classpath:discovery-response.json");
DiscoveryObject myJsonObject = objectMapper.readValue(discoveryJson, DiscoveryObject.class);

String fhirBase = StringUtils.removeEnd(customHapiProperties.getFhirBase(), "/");
String issuer = StringUtils.removeEnd(securityConfig.getIssuer(), "/");

myJsonObject.setAuthorization_endpoint(issuer + "/connect/authorize");
myJsonObject.setToken_endpoint(issuer + "/connect/token");
myJsonObject.setRegistration_endpoint(issuer + "/connect/register");

String signedMetadata = "";

FileInputStream stream = new FileInputStream(ResourceUtils.getFile("classpath:" + securityConfig.getCertFile()));
KeyStore ks = KeyStore.getInstance("pkcs12");
ks.load(stream, securityConfig.getCertPassword().toCharArray());
String alias = ks.aliases().nextElement();

X509Certificate certificate = (X509Certificate) ks.getCertificate(alias);
RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey();
RSAPrivateKey privateKey = (RSAPrivateKey) ks.getKey(alias, "udap-test".toCharArray());

Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
signedMetadata = JWT.create()
.withHeader(Map.of(
"alg", algorithm.getName(),
"x5c", new String[] { Base64.getEncoder().encodeToString(certificate.getEncoded()) }
))
.withIssuer(fhirBase)
.withSubject(fhirBase)
.withIssuedAt(Date.from(Instant.now()))
.withExpiresAt(Date.from(Instant.now()))
.withJWTId(UUID.randomUUID().toString())
.withClaim("authorization_endpoint", myJsonObject.getAuthorization_endpoint())
.withClaim("token_endpoint", myJsonObject.getToken_endpoint())
.withClaim("registration_endpoint", myJsonObject.getRegistration_endpoint())
.sign(algorithm);

myJsonObject.setSigned_metadata(signedMetadata);

//return the discovery object
theResponse.setContentType("application/json");
objectMapper.writeValue(theResponse.getOutputStream(), myJsonObject);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.server.RequestDetails;

import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
Expand All @@ -26,10 +30,15 @@
import javax.naming.AuthenticationException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
Expand All @@ -42,6 +51,7 @@ public class IdentityMatchingAuthInterceptor {
private String issuer;

private String publicKey;
private RSAPublicKey rsaPublicKey;
private String introspectUrl;
private String clientId;
private String clientSecret;
Expand Down Expand Up @@ -147,13 +157,54 @@ private boolean validateToken(String authHeader) throws NoSuchAlgorithmException
var token = authHeader.split(" ")[1];

//current set up for RSA 256, change as necessary
byte[] publicBytes = Base64.decodeBase64(publicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec);
// System.out.println("publicKey: " + publicKey);
// byte[] publicBytes = Base64.decodeBase64(publicKey);
// X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicBytes);
// KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec);

try {
Algorithm algorithm = Algorithm.RSA256(publicKey, null);

DecodedJWT decodedJWT = JWT.decode(token);
if (!decodedJWT.getIssuer().equals(issuer)) {
throw new JWTVerificationException("Invalid issuer: Expected \"" + issuer + "\" but received \"" + decodedJWT.getIssuer() + "\"");
}

// check if we already have the public key
if (rsaPublicKey == null) {

// check if the public key was supplied in the configuration and attempt to use it
if (!StringUtils.isEmpty(publicKey)) {
byte[] publicBytes = Base64.decodeBase64(publicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
rsaPublicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec);
}

// otherwise, attempt to retrieve the public key from the jwks endpoint
else {
HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(StringUtils.removeEnd(issuer, "/") + "/.well-known/openid-configuration"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String jwksUri = new ObjectMapper().readTree(response.body()).get("jwks_uri").asText();

JwkProvider provider = new UrlJwkProvider(new URL(jwksUri));
Jwk jwk = provider.get(decodedJWT.getKeyId());

rsaPublicKey = (RSAPublicKey) jwk.getPublicKey();
}

// if we still don't have the public key, throw an exception
if (rsaPublicKey == null) {
throw new JWTVerificationException("Could not determine public key");
}

}


Algorithm algorithm = Algorithm.RSA256(rsaPublicKey, null);
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(issuer)
.build(); //Reusable verifier instance
Expand All @@ -163,6 +214,9 @@ private boolean validateToken(String authHeader) throws NoSuchAlgorithmException

} catch (JWTVerificationException ex){
throw new JWTVerificationException(ex.getMessage());
} catch (Exception ex) {
System.err.println("Exception: " + ex.getMessage());
throw new RuntimeException(ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,37 @@
package ca.uhn.fhir.jpa.starter.security.models;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Configuration
@ConfigurationProperties(prefix = "security")
public class SecurityConfig {
@Value("${enable-authentication}")
boolean enableAuthentication;
@Value("${issuer}")
@Getter @Setter
Boolean enableAuthentication;
@Getter @Setter
String issuer;
@Value("${public-key}")
@Getter @Setter
String publicKey;
@Value("${introspection-url}")
@Getter @Setter
String introspectionUrl;
@Value("${client-id}")
@Getter @Setter
String clientId;
@Value("${client-secret}")
@Getter @Setter
String clientSecret;
@Value("${protected-endpoints}")
@Setter
List<String> protectedEndpoints = new ArrayList<>();
@Value("${public-endpoints}")
@Setter
List<String> publicEndpoints = new ArrayList<>();

public boolean isEnableAuthentication() { return enableAuthentication; }

public String getIssuer() { return issuer; }

public String getPublicKey() { return publicKey; }

public String getIntrospectionUrl() { return introspectionUrl; }

public String getClientId() { return clientId; }

public String getClientSecret() { return clientSecret; }
@Getter @Setter
String certFile;
@Getter @Setter
String certPassword;

public List<String> getProtectedEndpoints() {
if(this.protectedEndpoints.size() > 0) {
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ security:
introspection-url:
client-id:
client-secret:
issuer: https://localhost:5001
public-key:
protected-endpoints:
public-endpoints:
- /fhir/metadata
- /fhir/.well-known/udap
cert-file: client-cert.pfx
cert-password: udap-test

spring:
main:
allow-circular-references: true
Expand Down
12 changes: 6 additions & 6 deletions src/main/resources/discovery-response.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
{
"udap_versions_supported": ["test"],
"udap_profiles_supported": ["udap_dcr", "udap_authn"],
"udap_versions_supported": ["1"],
"udap_profiles_supported": ["udap_dcr", "udap_authn", "udap_authz"],
"udap_authorization_extensions_supported": ["hl7-b2b"],
"udap_authorization_extensions_required": ["hl7-b2b"],
"udap_certifications_supported": ["https://www.example.com/udap/profiles/example-certification"],
"udap_certifications_required": ["https://www.example.com/udap/profiles/example-certification"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"scopes_supported": ["openid", "launch/patient", "system/Patient.read", "system/AllergyIntolerance.read", "system/Procedures.read"],
"authorization_endpoint": "https://www.example-ip.com/authorization",
"token_endpoint": "https://www.example-ip.com/token",
"scopes_supported": ["openid", "patient/*.read", "patient/*.rs", "user/*.read", "user/*.rs", "system/*.read", "system/*.rs"],
"authorization_endpoint": "",
"token_endpoint": "",
"token_endpoint_auth_methods_supported": ["private_key_jwt"],
"token_endpoint_auth_signing_alg_values_supported": ["RS256", "ES384"],
"registration_endpoint": "https://www.my-fhir-server.com/fhir/register",
"registration_endpoint": "",
"registration_endpoint_jwt_signing_alg_values_supported": ["RS256", "ES384"],
"signed_metadata": "example-meta-data"
}

0 comments on commit 136d88b

Please sign in to comment.