Skip to content

Commit

Permalink
[BACK-3228] Add Patient FHIR resource to session note mapper
Browse files Browse the repository at this point in the history
  • Loading branch information
toddkazakov committed Oct 29, 2024
1 parent d472499 commit a4cc856
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<IdentityProviderModel> idp = context.getRealm().getIdentityProvidersStream()
.filter(IdentityProviderModel::isEnabled)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -64,4 +66,10 @@ public List<ProviderConfigProperty> getConfigProperties() {
public String getId() {
return PROVIDER_ID;
}

@Override
public void init(Config.Scope config) {
// Load the FHIR Context on startup
FHIRContext.getR4();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}

}

Original file line number Diff line number Diff line change
@@ -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<String, PatientRepresentation> {

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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();

private static final Set<IdentityProviderSyncMode> 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<ProviderConfigProperty> 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();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.tidepool.keycloak.extensions.broker.mappers.PatientsUserSessionNoteMapper

0 comments on commit a4cc856

Please sign in to comment.