From a4cc8568ba5cd1b84f36becb21466c0bdf04e7a7 Mon Sep 17 00:00:00 2001 From: Todd Kazakov Date: Tue, 29 Oct 2024 15:32:25 +0200 Subject: [PATCH] [BACK-3228] Add Patient FHIR resource to session note mapper --- .../SMARTIdentityProviderAuthenticator.java | 27 +++- .../extensions/broker/FHIRContext.java | 17 ++ .../broker/SMARTIdentityProviderFactory.java | 10 +- .../broker/mappers/PatientRepresentation.java | 66 ++++++++ .../mappers/PatientsUserSessionNote.java | 30 ++++ .../PatientsUserSessionNoteMapper.java | 150 ++++++++++++++++++ ...oak.broker.provider.IdentityProviderMapper | 1 + 7 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientRepresentation.java create mode 100644 admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNote.java create mode 100644 admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNoteMapper.java create mode 100644 admin/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/SMARTIdentityProviderAuthenticator.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/SMARTIdentityProviderAuthenticator.java index a638326..a378e45 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/SMARTIdentityProviderAuthenticator.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/SMARTIdentityProviderAuthenticator.java @@ -2,8 +2,10 @@ import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.Authenticator; +import org.keycloak.events.Errors; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -22,21 +24,36 @@ public class SMARTIdentityProviderAuthenticator implements Authenticator { protected static final String ACCEPTS_PROMPT_NONE = "acceptsPromptNoneForwardFromClient"; - private static final String ISSUER = "iss"; + public static final String CORRELATION_ID = "correlation_id"; @Override public void authenticate(AuthenticationFlowContext context) { - String issuer = context.getUriInfo().getQueryParameters().getFirst(ISSUER); + String issuer = context.getUriInfo().getQueryParameters().getFirst(OIDCLoginProtocol.ISSUER); if (issuer == null || issuer.isBlank()) { - LOG.warnf("No issuer set or %s query parameter provided", ISSUER); - context.attempted(); + LOG.warnf("No issuer %s query parameter provided", OIDCLoginProtocol.ISSUER); + respondWithInvalidRequest(context, OIDCLoginProtocol.ISSUER + "query parameter is required"); return; } - LOG.infof("Redirecting: %s set to %s", ISSUER, issuer); + String correlationId = context.getUriInfo().getQueryParameters().getFirst(CORRELATION_ID); + if (correlationId == null || issuer.isBlank()) { + LOG.warnf("No correlationId %s query parameter provided", CORRELATION_ID); + respondWithInvalidRequest(context, CORRELATION_ID + "query parameter is required"); + return; + } + + context.getAuthenticationSession().setClientNote(CORRELATION_ID, correlationId); + + LOG.infof("Redirecting with correlationId %s: %s set to %s", correlationId, OIDCLoginProtocol.ISSUER, issuer); redirect(context, issuer); } + protected void respondWithInvalidRequest(AuthenticationFlowContext context, String errorMessage) { + context.getEvent().error(Errors.IDENTITY_PROVIDER_ERROR); + Response challenge = context.form().setError("Invalid request: " + errorMessage).createErrorPage(Response.Status.BAD_REQUEST); + context.failure(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challenge, "Invalid request", errorMessage); + } + protected void redirect(AuthenticationFlowContext context, String issuer) { Optional idp = context.getRealm().getIdentityProvidersStream() .filter(IdentityProviderModel::isEnabled) diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/FHIRContext.java b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/FHIRContext.java index 03b7677..1fabf98 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/FHIRContext.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/FHIRContext.java @@ -1,6 +1,10 @@ package org.tidepool.keycloak.extensions.broker; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.client.api.IClientInterceptor; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.interceptor.BearerTokenAuthInterceptor; +import org.keycloak.broker.provider.IdentityBrokerException; public class FHIRContext { // Singleton instance - the creation of this object is expensive @@ -11,4 +15,17 @@ private FHIRContext() {} public static FhirContext getR4() { return R4; } + + public static IGenericClient getFHIRClient(String version, String baseUrl, String accessToken) { + if (!SMARTIdentityProviderFactory.FHIR_R4.equals(version)) { + throw new IdentityBrokerException("Unsupported FHIR Version: " + version); + } + + FhirContext ctx = FHIRContext.getR4(); + IClientInterceptor authInterceptor = new BearerTokenAuthInterceptor(accessToken); + IGenericClient client = ctx.newRestfulGenericClient(baseUrl); + client.registerInterceptor(authInterceptor); + + return client; + } } diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/SMARTIdentityProviderFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/SMARTIdentityProviderFactory.java index e9d6b87..c5ea749 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/SMARTIdentityProviderFactory.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/SMARTIdentityProviderFactory.java @@ -1,5 +1,6 @@ package org.tidepool.keycloak.extensions.broker; +import org.keycloak.Config; import org.keycloak.broker.provider.AbstractIdentityProviderFactory; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; @@ -16,7 +17,8 @@ public class SMARTIdentityProviderFactory extends AbstractIdentityProviderFactor public static final String PROVIDER_ID = "smart"; - public static final String[] SUPPORTED_FHIR_VERSIONS = {SMARTIdentityProvider.FHIR_R4}; + public static final String FHIR_R4 = "R4"; + public static final String[] SUPPORTED_FHIR_VERSIONS = {FHIR_R4}; @Override public String getName() { @@ -64,4 +66,10 @@ public List getConfigProperties() { public String getId() { return PROVIDER_ID; } + + @Override + public void init(Config.Scope config) { + // Load the FHIR Context on startup + FHIRContext.getR4(); + } } diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientRepresentation.java b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientRepresentation.java new file mode 100644 index 0000000..94d6de8 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientRepresentation.java @@ -0,0 +1,66 @@ +package org.tidepool.keycloak.extensions.broker.mappers; + +import org.hl7.fhir.r4.model.Patient; + +import java.time.Instant; + +public class PatientRepresentation { + public String id; + public String firstName; + public String lastName; + public String mrn; + public Long timestamp; + + public PatientRepresentation(Patient patient) { + id = patient.getId(); + firstName = patient.getNameFirstRep().getGivenAsSingleString(); + lastName = patient.getNameFirstRep().getFamily(); + mrn = patient.getIdentifierFirstRep().getValue(); + + // Capture the time when the patient representation was instantiated + // to allow for LRU pruning if needed + timestamp = Instant.now().getEpochSecond(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getMrn() { + return mrn; + } + + public void setMrn(String mrn) { + this.mrn = mrn; + } + + public Long getTimestamp() { + return timestamp; + } + + public void setTimestamp(Long timestamp) { + this.timestamp = timestamp; + } + +} + diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNote.java b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNote.java new file mode 100644 index 0000000..361245d --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNote.java @@ -0,0 +1,30 @@ +package org.tidepool.keycloak.extensions.broker.mappers; + +import org.hl7.fhir.r4.model.Patient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.HashMap; + +public class PatientsUserSessionNote extends HashMap { + + public PatientsUserSessionNote() { + super(); + } + + public void addPatient(String correlationId, Patient patient) { + this.put(correlationId, new PatientRepresentation(patient)); + } + + public String serializeAsString() throws IOException { + return JsonSerialization.writeValueAsString(this); + } + + public static PatientsUserSessionNote deserializeFromString(String value) throws IOException { + if (value == null || value.isBlank()) { + return new PatientsUserSessionNote(); + } + + return JsonSerialization.readValue(value, PatientsUserSessionNote.class); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNoteMapper.java b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNoteMapper.java new file mode 100644 index 0000000..c1617b1 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/broker/mappers/PatientsUserSessionNoteMapper.java @@ -0,0 +1,150 @@ +package org.tidepool.keycloak.extensions.broker.mappers; + +import ca.uhn.fhir.rest.client.api.IGenericClient; +import org.hl7.fhir.r4.model.Patient; +import org.jboss.logging.Logger; +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.provider.AbstractIdentityProviderMapper; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderSyncMode; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.services.managers.AuthenticationManager; +import org.tidepool.keycloak.extensions.broker.FHIRContext; +import org.tidepool.keycloak.extensions.broker.SMARTIdentityProvider; +import org.tidepool.keycloak.extensions.broker.SMARTIdentityProviderFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.tidepool.keycloak.extensions.authenticator.SMARTIdentityProviderAuthenticator.CORRELATION_ID; + + +public class PatientsUserSessionNoteMapper extends AbstractIdentityProviderMapper { + + private static final Logger LOG = Logger.getLogger(PatientsUserSessionNoteMapper.class); + + private static final String PATIENTS_NOTE_NAME = "smart/patients"; + + private static final String[] COMPATIBLE_PROVIDERS = { ANY_PROVIDER }; + + private static final List CONFIG_PROPERTIES = new ArrayList<>(); + + private static final Set IDENTITY_PROVIDER_SYNC_MODES = + new HashSet<>(Arrays.asList(IdentityProviderSyncMode.values())); + + public static final String PROVIDER_ID = "smart-patients-session-note-idp-mapper"; + + @Override + public String[] getCompatibleProviders() { + return COMPATIBLE_PROVIDERS; + } + + @Override + public String getDisplayCategory() { + return "User Session"; + } + + @Override + public String getDisplayType() { + return "Patient User Session Note Mapper"; + } + + @Override + public String getHelpText() { + return "Adds the patient in context to the user session note."; + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) { + return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode); + } + + @Override + public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, + IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + addPatientToSessionNote(session, realm, context); + } + + @Override + public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, + IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { + addPatientToSessionNote(session, realm, context); + } + + private void addPatientToSessionNote(KeycloakSession session, RealmModel realm, BrokeredIdentityContext context) { + String correlationId = context.getAuthenticationSession().getClientNote(CORRELATION_ID); + if (correlationId.isBlank()) { + LOG.warnf("Client correlationId is not defined for brokered user %s", context.getBrokerUserId()); + return; + } + + AccessTokenResponse accessTokenResponse = (AccessTokenResponse) context.getContextData().get(OIDCIdentityProvider.FEDERATED_ACCESS_TOKEN_RESPONSE); + Object patientId = accessTokenResponse.getOtherClaims().getOrDefault("patient", ""); + if (!(patientId instanceof String) || ((String)patientId).isBlank()) { + LOG.warnf("Patient id not found in access token response for brokered user %s", context.getBrokerUserId()); + return; + } + + String fhirVersion = (String) context.getContextData().get(SMARTIdentityProvider.FHIR_VERSION); + String fhirBaseURL = (String) context.getContextData().get(SMARTIdentityProvider.FHIR_BASE_URL); + + IGenericClient client = FHIRContext.getFHIRClient(fhirVersion, fhirBaseURL, accessTokenResponse.getToken()); + Patient patient = client.read().resource(Patient.class).withId((String)patientId).execute(); + + PatientsUserSessionNote patients; + String noteValue; + + // There may be multiple authentication sessions for a single SSO session. + // Retrieve the session notes from the user session, to make sure we are appending + // to the list of patients associated to the SSO session, not to the current auth session. + UserSessionModel userSession = this.getUserSession(session, realm); + if (userSession != null) { + noteValue = userSession.getNote(PATIENTS_NOTE_NAME); + try { + patients = PatientsUserSessionNote.deserializeFromString(noteValue); + } catch (IOException e) { + LOG.warnf("Unable to deserialize patient notes: %s", noteValue); + return; + } + } else { + patients = new PatientsUserSessionNote(); + } + + patients.addPatient(correlationId, patient); + try { + context.setSessionNote(PATIENTS_NOTE_NAME, patients.serializeAsString()); + } catch (IOException e) { + LOG.warnf("Unable to serialize patient notes: %s", e.getMessage()); + } + } + + private UserSessionModel getUserSession(KeycloakSession session, RealmModel realm) { + AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(session, realm, true); + if (authResult == null) { + return null; + } + return authResult.getSession(); + } +} + diff --git a/admin/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper b/admin/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper new file mode 100644 index 0000000..7ac29d6 --- /dev/null +++ b/admin/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper @@ -0,0 +1 @@ +org.tidepool.keycloak.extensions.broker.mappers.PatientsUserSessionNoteMapper