Skip to content

Commit

Permalink
FI-2424: Support for SMART asymmetric confidential client auth (#139)
Browse files Browse the repository at this point in the history
  • Loading branch information
dehall authored Mar 21, 2024
1 parent 2b3c4a4 commit 32bda5c
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 31 deletions.
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@
<artifactId>java-jwt</artifactId>
<version>3.8.3</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>jwks-rsa</artifactId>
<version>0.22.1</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.json/json -->
<dependency>
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/org/mitre/fhir/HapiReferenceServerProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public class HapiReferenceServerProperties {
private static final String CONFIDENTIAL_CLIENT_ID_KEY = "inferno.confidential_client_id";
private static final String CONFIDENTIAL_CLIENT_SECRET_KEY = "inferno.confidential_client_secret";
private static final String BULK_CLIENT_ID = "inferno.bulk_client_id";
private static final String ASYMMETRIC_CLIENT_ID = "inferno.asymmetric_client_id";
private static final String ASYMMETRIC_CLIENT_JWKS = "inferno.asymmetric_client_jwks";
private static final String GROUP_ID = "inferno.group_id";
private static final String RESOURCES_FOLDER = "inferno.resources_folder";

Expand Down Expand Up @@ -296,6 +298,26 @@ public String getBulkClientId() {
return bulkClientId;
}

/**
* Returns the Asymmetric Client ID Property.
*
* @return the property
*/
public String getAsymmetricClientId() {
String asymmetricClientId = properties.getProperty(ASYMMETRIC_CLIENT_ID);
return asymmetricClientId;
}

/**
* Returns the Asymmetric Client JWKS Property.
*
* @return the property
*/
public String getAsymmetricClientJwks() {
String asymmetricClientJwks = properties.getProperty(ASYMMETRIC_CLIENT_JWKS);
return asymmetricClientJwks;
}

/**
* Returns the Group Id Property.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.CacheControlDirective;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import com.auth0.jwk.InvalidPublicKeyException;
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.SigningKeyNotFoundException;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.github.dnault.xmlpatch.internal.Log;
import java.math.BigInteger;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
Expand All @@ -22,7 +30,6 @@
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.PostConstruct;
Expand Down Expand Up @@ -60,10 +67,10 @@
@RestController
public class AuthorizationController {

private static final String BULK_EXPECTED_GRANT_TYPE = "client_credentials";
private static final String CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials";
private static final String AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code";
private static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token";
private static final String BULK_EXPECTED_CLIENT_ASSERTION_TYPE =
private static final String JWT_BEARER_CLIENT_ASSERTION_TYPE =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";

@PostConstruct
Expand Down Expand Up @@ -107,15 +114,15 @@ public ResponseEntity<String> getTokenByBackendServiceAuthorization(
validateBulkDataScopes(scopeString);

// check grant_type
if (!BULK_EXPECTED_GRANT_TYPE.equals(grantType)) {
if (!CLIENT_CREDENTIALS_GRANT_TYPE.equals(grantType)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Grant Type should be " + BULK_EXPECTED_GRANT_TYPE);
"Grant Type should be " + CLIENT_CREDENTIALS_GRANT_TYPE);
}

// check client_assertion_type
if (!BULK_EXPECTED_CLIENT_ASSERTION_TYPE.equals(clientAssertionType)) {
if (!JWT_BEARER_CLIENT_ASSERTION_TYPE.equals(clientAssertionType)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Client Assertion Type should be " + BULK_EXPECTED_CLIENT_ASSERTION_TYPE);
"Client Assertion Type should be " + JWT_BEARER_CLIENT_ASSERTION_TYPE);
}

// validate client_assertion (jwt)
Expand All @@ -127,7 +134,7 @@ public ResponseEntity<String> getTokenByBackendServiceAuthorization(
+ "0.q4v4Msc74kN506KTZ0q_minyapJw0gwlT6M_uiL73S4";
if (!clientId.equals(decodedJwt.getIssuer())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Issuer should be " + BULK_EXPECTED_CLIENT_ASSERTION_TYPE);
"Issuer should be " + clientId);
}

TokenManager tokenManager = TokenManager.getInstance();
Expand Down Expand Up @@ -214,7 +221,7 @@ public ResponseEntity<String> getToken(

Log.info("code is " + code);

if (BULK_EXPECTED_GRANT_TYPE.equals(grantType)) {
if (CLIENT_CREDENTIALS_GRANT_TYPE.equals(grantType)) {
return getTokenByBackendServiceAuthorization(
scopes,
grantType,
Expand All @@ -228,10 +235,8 @@ public ResponseEntity<String> getToken(
"Bad Grant Type: " + grantType);
}

String clientId = validateClient(request, clientIdRequestParam);

String patientId = "";
String encounterId = "";
String clientId =
validateClient(request, clientIdRequestParam, clientAssertionType, clientAssertion);

if (code != null) {
return validateCode(request, code, clientId, codeVerifier);
Expand All @@ -240,8 +245,8 @@ public ResponseEntity<String> getToken(
try {
if (TokenManager.getInstance().authenticateRefreshToken(refreshTokenValue)) {
Token refreshToken = TokenManager.getInstance().getRefreshToken(refreshTokenValue);
patientId = refreshToken.getPatientId();
encounterId = refreshToken.getEncounterId();
String patientId = refreshToken.getPatientId();
String encounterId = refreshToken.getEncounterId();
String refreshTokenScopes = refreshToken.getScopesString();
return generateBearerTokenResponse(request, clientId, refreshTokenScopes, patientId,
encounterId);
Expand All @@ -255,25 +260,76 @@ public ResponseEntity<String> getToken(
"No code or refresh token provided.");
}

private static String validateClient(HttpServletRequest request, String clientIdRequestParam) {
// check client id and client secret if the server is confidential
String basicHeader = getBasicHeader(request);

private static String validateClient(HttpServletRequest request, String clientIdRequestParam,
String clientAssertionType, String clientAssertion) {
String clientId;
String clientSecret = null;

// if basic header exists, extract clientId and clientSecret from basic header
if (basicHeader != null) {
String decodedValue = getDecodedBasicAuthorizationString(basicHeader);
String[] splitDecodedValue = decodedValue.split(":");
// client id is username, and should be before ':'
clientId = splitDecodedValue[0];
// client secret is password, and should be after ':'
if (clientAssertionType == null) {
// check client id and client secret if the server is confidential
String basicHeader = getBasicHeader(request);

// if basic header exists, extract clientId and clientSecret from basic header
if (basicHeader != null) {
String decodedValue = getDecodedBasicAuthorizationString(basicHeader);
String[] splitDecodedValue = decodedValue.split(":");
// client id is username, and should be before ':'
clientId = splitDecodedValue[0];
// client secret is password, and should be after ':'

clientSecret = splitDecodedValue.length >= 2 ? splitDecodedValue[1] : "";
} else {
// if no basic auth, client id should be supplied as request param
clientId = clientIdRequestParam;
}
} else if (JWT_BEARER_CLIENT_ASSERTION_TYPE.equals(clientAssertionType)) {
// confidential asymmetric
DecodedJWT decodedJwt = JWT.decode(clientAssertion);
clientId = decodedJwt.getIssuer();

try {
// In this case we cache the JWKS file locally, but
// this verification is normally done against the registered JWKS for the given client, eg:
// JwkProvider provider = new UrlJwkProvider("https://inferno.healthit.gov/suites/custom/smart_stu2/");
HapiReferenceServerProperties properties = new HapiReferenceServerProperties();
URL jwks = AuthorizationController.class.getResource(properties.getAsymmetricClientJwks());
JwkProvider provider = new UrlJwkProvider(jwks);
Jwk jwk = provider.get(decodedJwt.getKeyId());
Algorithm algorithm;
if (decodedJwt.getAlgorithm().equals("RS384")) {
algorithm = Algorithm.RSA384((RSAPublicKey) jwk.getPublicKey(), null);
} else if (decodedJwt.getAlgorithm().equals("ES384")) {
algorithm = Algorithm.ECDSA384((ECPublicKey) jwk.getPublicKey(), null);
} else {
// the above are the only 2 options supported in the SMART app launch test kit.
// if more are added, report support for them in WellKnownAuthorizationEndpointController
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unsupported encryption method " + decodedJwt.getAlgorithm());
}

algorithm.verify(decodedJwt);
} catch (SignatureVerificationException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Client Assertion JWT failed signature verification", e);
} catch (SigningKeyNotFoundException e) {
// thrown by provider.get(jwt.kid) above
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"No key found with kid " + decodedJwt.getKeyId(), e);
} catch (InvalidPublicKeyException e) {
// thrown by jwk.getPublicKey above, should never happen
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Failed to parse public key", e);
} catch (JwkException e) {
// thrown by provider.get(jwt.kid) above,
// shouldn't be possible in practice as the method only throws
// the more specific SigningKeyNotFound
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Unknown error occurred", e);
}

clientSecret = splitDecodedValue.length >= 2 ? splitDecodedValue[1] : "";
} else {
// if no basic auth, client id should be supplied as request param
clientId = clientIdRequestParam;
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unexpected Client Assertion Type: " + clientAssertionType);
}

authenticateClientIdAndClientSecret(clientId, clientSecret);
Expand Down Expand Up @@ -548,7 +604,8 @@ private static String getDecodedBasicAuthorizationString(String basicHeader) {
private static void authorizeClientId(String clientId) {
HapiReferenceServerProperties properties = new HapiReferenceServerProperties();
if (!properties.getPublicClientId().equals(clientId)
&& !properties.getConfidentialClientId().equals(clientId)) {
&& !properties.getConfidentialClientId().equals(clientId)
&& !properties.getAsymmetricClientId().equals(clientId)) {
throw new InvalidClientIdException(clientId);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.io.IOException;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import org.json.JSONArray;
Expand Down Expand Up @@ -34,6 +35,7 @@ public class WellKnownAuthorizationEndpointController {
"launch-ehr",
"launch-standalone",
"client-public",
"client-confidential-asymmetric",
"client-confidential-symmetric",
"sso-openid-connect",
"context-banner",
Expand Down Expand Up @@ -94,6 +96,9 @@ public String getWellKnownJson(HttpServletRequest theRequest) {
FhirReferenceServerUtils.getFhirServerBaseUrl(theRequest) + "/.well-known/jwk");
wellKnownJson.put(WELL_KNOWN_INTROSPECTION_ENDPOINT_KEY,
ServerConformanceWithAuthorizationProvider.getIntrospectExtensionUri(theRequest));
wellKnownJson.put("token_endpoint_auth_methods_supported", List.of("private_key_jwt"));
wellKnownJson.put("token_endpoint_auth_signing_alg_values_supported",
List.of("RS384", "ES384"));

return wellKnownJson.toString();
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/hapi.properties
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ inferno.public_client_id=SAMPLE_PUBLIC_CLIENT_ID
inferno.confidential_client_id=SAMPLE_CONFIDENTIAL_CLIENT_ID
inferno.confidential_client_secret=SAMPLE_CONFIDENTIAL_CLIENT_SECRET
inferno.bulk_client_id=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJlZ2lzdHJhdGlvbi10b2tlbiJ9.eyJqd2tzX3VybCI6Imh0dHA6Ly8xMC4xNS4yNTIuNzMvaW5mZXJuby8ud2VsbC1rbm93bi9qd2tzLmpzb24iLCJhY2Nlc3NUb2tlbnNFeHBpcmVJbiI6MTUsImlhdCI6MTU5NzQxMzE5NX0.q4v4Msc74kN506KTZ0q_minyapJw0gwlT6M_uiL73S4
inferno.asymmetric_client_id=SAMPLE_ASYMMETRIC_CLIENT_ID
inferno.asymmetric_client_jwks=/inferno_client_jwks.json
inferno.group_id=64fdf2a5-ebad-4ed0-a512-567970843d49
inferno.resources_folder=./resources
29 changes: 29 additions & 0 deletions src/main/resources/inferno_client_jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-384",
"x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C",
"y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw",
"use": "sig",
"key_ops": [
"verify"
],
"ext": true,
"kid": "4b49a739d1eb115b3225f4cf9beb6d1b",
"alg": "ES384"
},
{
"kty": "RSA",
"alg": "RS384",
"n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw",
"e": "AQAB",
"use": "sig",
"key_ops": [
"verify"
],
"ext": true,
"kid": "b41528b6f37a9500edb8a905a595bdd7"
}
]
}
Loading

0 comments on commit 32bda5c

Please sign in to comment.