The Smile CDR Inbound and Outbound Security modules support a JavaScript based callback API that can be used to add custom logic to the authentication and authorization process. Callback scripts have the right to examine authentication requests, enhance or restrict the corresponding user session, and even reject the authentication entirely.
The onAuthenticateSuccess
method is invoked after a user has successfully authenticated with an inbound security module. In other words, this is invoked after a user's credentials have been validated and a set of user details (potentially including the user's name, permissions, etc.) has been assembled.
The script has the ability to modify the user's details. This might consist of steps such as:
theUserSession
– This object contains details about the request, including the authorization and authentication tokens. This object is of type UserSessionDetails.
theOutcomeFactory
– This object is used to create a success or failure object to be returned by the function. This object is of type ScriptAuthenticationOutcomeFactory.
theContext
– The context object contains details about the environment in which the authorization occurred. The datatype for this object will vary depending on the specific inbound security module being used. See the individual module type documentation for more information. All module types will provide a context that provides at least the properties and functions of the AuthenticationContext. Different Inbound Security modules will provide a specific subclass of AuthenticationContext that has relevant properties and operations for that module type:
The following is a simple example of an onAuthenticateSuccess callback function:
function onAuthenticateSuccess(theOutcome, theOutcomeFactory, theContext) {
var username = theOutcome.username;
var remoteAddress = theContext.remoteAddress;
Log.info('User ' + username + ' is logging in from ' + remoteAddress);
// Don't allow the admin user to log in from hosts other than localhost
// Note that the username will be capitalized if the security module is not case sensitive
if (username === 'ADMIN' && remoteAddress != '127.0.0.1') {
var failure = theOutcomeFactory.newFailure();
failure.message = 'Can not log in as admin from this address';
return failure;
}
// Otherwise, just return default (successful) outcome
return theOutcome;
}
Typically, it is sufficient to simply return the authorization outcome that was already generated by the system as shown above. It is also possible to modify the authorization. The most common use case is to add or remove permissions based on some criteria.
function onAuthenticateSuccess(theOutcome, theOutcomeFactory, theContext) {
if (theOutcome.username === 'admin123') {
theOutcome.addAuthority('ROLE_FHIR_CLIENT_SUPERUSER_RO');
}
return theOutcome;
}
The following example shows a failure being returned:
// Create a failure object to return
var failure = theOutcomeFactory.newFailure();
// The following properties are optional
failure.message = 'Login failed due to invalid credentials';
failure.unknownUsername = false;
failure.incorrectPassword = true;
return failure;
Note the properties that may optionally be populated on the failure object. These properties supply additional details about the failure and can be useful for troubleshooting; however, they are not required to be populated. A failure object with no properties is still treated as a normal authentication failure.
message
property is used to supply a human-readable description of why the authentication failed.unknownUsername
property may be set to true
to indicate that the authentication failed due to an unknown or invalid username.incorrectPassword
property may be set to true
to indicate that the authentication failed due to an incorrect password for the given username.Some solutions may have custom authorization needs that do not map easily to existing authorities or roles. For these cases, the UserSessionDetails object exposes a userData key/value collection similarly to FHIR resources. For example, a SMART inbound hook can inspect custom claims and set user data:
function onAuthenticateSuccess(theOutcome, theOutcomeFactory, theContext) {
if (theContext.getClaim('patientRepresentative') != null) {
theOutcome.setUserData('patientRepresentative', true);
}
return theOutcome;
}
This user data could then be used in the Consent Service to block or mask data:
function consentWillSeeResource(theRequestDetails, theUserSession, theContextServices, theResource, theClientSession) {
if (theUserSession.hasUserData('patientRepresentative')) {
// clear some sensitive data.
theResource.clear('value');
}
}
Due to a known issue in higher versions of Java, attempts to save complex objects to UserData during onTokenGenerating will often throw an error.
As such, it's better to serialize any non-primitive, non-string values before saving them to UserData.
function onTokenGenerating(theUserSession, theAuthorizationRequestDetails) {
const myComplexObj = {
"property": "value",
"bool_property": true
};
// save the object to UserData by serializing first
const serialized = JSON.stringify(myComplexObj);
theUserSession.setUserData("userDataField", serialized);
}
NB: This can also be fixed by setting JVMARGS=\"$JVMARGS --add-opens=java.base/java.util=ALL-UNNAMED
in the setenv file.
If present in the Post-Authorize Script defined in the SMART Outbound Security module, the onSmartLoginPreContextSelection function will be called after the user has authenticated. It should supply the context choices that will be presented to the user. See SMART on FHIR Outbound Security Module: Context Selection for more information on this flow.
It can also elect to not supply any choices. In this case, the authorization flow will continue as normal.
This function has two arguments:
theUserSession
(argument 0) – This object contains details about the authenticated user and their session. This includes demographics, permissions, approved scopes, etc. The object will be of type UserSessionDetailsJson.theContextSelectionChoices
(argument 1) – This object is a holder for the selection choices. The function should add all options that the user will be allowed to choose from. If the script does not modify this object, the user will not be presented with the context selection screen and no changes will be made to the session launch context. This object is of type OAuth2SmartContextSelectionChoices.This function should not return any value.
The following example shows a script that provides the user with two patient context options to select from.
/**
* This function is called immediately after the user authenticates with the server,
* in order to determine whether a context selection is needed.
*
* It should return the allowable contexts, which the user will then be allowed
* to choose from.
*
* @param theUserSession Contains details about the logged in user and
* their session.
* @param theContextSelectionChoices This object should be manipulated in the
* function in order to provide the choices
* the user can select from.
*/
function onSmartLoginPreContextSelection(theUserSession, theContextSelectionChoices) {
/*
* In this example, we're just going to hard code 2 choices. These could be pulled from
* a FHIR repository (e.g. using Consent resources) or could even be pulled from a 3rd
* party system.
*/
let person1 = theContextSelectionChoices.addPerson();
person1.familyName = 'Simpson';
person1.givenName = 'Homer';
person1.birthDate = '1950-01-12';
// This is the resource ID that will ultimately be put into the launch context if this
// person is selected by the user.
person1.associatedPatientContextResourceId = "context-patient-id-111";
// Any authorities added to the person record will be granted to the session only
// if this person is picked. This can be used to grant appropriate permissions
// to access the selected entity.
person1.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', 'Patient/' + person1.associatedPatientContextResourceId)
/*
* Add a second person to choose from
*/
let person2 = theContextSelectionChoices.addPerson();
person2.familyName = 'Simpson';
person2.givenName = 'Marge';
person2.birthDate = '1954-06-22';
person2.associatedPatientContextResourceId = "context-patient-id-222";
person2.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', 'Patient/' + person2.associatedPatientContextResourceId);
person2.addAutoGrantScopes('openid');
person2.addRequestedScopes('launch profile patient/*.read system/Patient.read');
let person3 = theContextSelectionChoices.addPerson();
person3.familyName = 'Simpson';
person3.givenName = 'Bart';
person3.birthDate = '1979-12-17';
person3.associatedPatientContextResourceId = "context-patient-id-333";
person3.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', 'Patient/' + person3.associatedPatientContextResourceId);
person3.removeRequestedScopes('patient/*.read profile');
let person4 = theContextSelectionChoices.addPerson();
person4.familyName = 'Simpson';
person4.givenName = 'Lisa';
person4.birthDate = '1981-05-09';
person4.associatedPatientContextResourceId = "context-patient-id-444";
person4.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', 'Patient/' + person4.associatedPatientContextResourceId);
person4.addAutoGrantScopes('patient/*.write system/Patient.read');
person4.removeAutoGrantScopes('patient/*.write');
let person5 = theContextSelectionChoices.addPerson();
person5.familyName = 'Simpson';
person5.givenName = 'Maggie';
person5.birthDate = '1989-01-14';
person5.associatedPatientContextResourceId = "context-patient-id-555";
person5.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', 'Patient/' + person5.associatedPatientContextResourceId);
person5.addRequestedScopes('launch profile patient/*.read system/Patient.read patient/Observation.read');
person5.removeRequestedScopes('system/Patient.read');
person5.addAutoGrantScopes('system/Observation.read patient/*.write');
person5.removeAutoGrantScopes('patient/*.write');
let person6 = theContextSelectionChoices.addPerson();
person6.familyName = 'Simpson';
person6.givenName = 'Herb';
person6.birthDate = '1948-01-12';
person6.associatedPatientContextResourceId = "context-patient-id-123";
person6.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', 'Patient/' + person6.associatedPatientContextResourceId);
person6.addRequestedScopes('launch profile patient/*.read system/Patient.read patient/Observation.read');
theContextSelectionChoices.addDisplayTranslation('system/Patient.read', 'Custom Display');
}
The onTokenGenerating function is called immediately before an access token is generated following a successful authentication. It is primarily used in order to customize the SMART launch context(s) associated with a particular session (ie. because the launch context is maintained in a third-party application and needs to be looked up during the auth flow).
theUserSession – This parameter is of type UserSessionDetails. This will contain details about the user and their session, as supplied by the connected Inbound Security module. Scripts may modify this object. Note that if the authentication is happening for a client as opposed to a user (i.e. using the Client Credentials Grant type) this parameter will be null
, but must still exist in the method signature.
theAuthorizationRequestDetails – This parameter is of type OAuth2AuthorizationRequestDetails. It will contain details about the OAuth2 authorization request.
/**
* This function is called just prior to the creation and issuing of a new
* access token.
*
* @param theUserSession The authenticated user session (can be modifued by the script)
* @param theAuthorizationRequestDetails Contains details about the authorization request
*/
function onTokenGenerating(theUserSession, theAuthorizationRequestDetails) {
// Here we will just log the launch parameter and hardcode the launch contexts
// to use. It would be possible however to use the launch parameter as input
// to an algorithm/lookup/etc in order to set the launch context.
Log.info("Generating token for launch: " + theAuthorizationRequestDetails.launch)
theUserSession.addLaunchResourceId('patient', '555');
theUserSession.addLaunchResourceId('encounter', '666');
theUserSession.addLaunchResourceId('location', '777');
theUserSession.addLaunchContextParameter('smart_style_url', 'http://acme.org/styles/smart_v1.json');
}
The following example shows how to add custom claims to the generated access token.
/**
* This function is called just prior to the creation and issuing of a new
* access token.
*
* @param theUserSession The authenticated user session (can be modified by the script) or null if a client is authenticating as opposed to a user
* @param theAuthorizationRequestDetails Contains details about the authorization request
*/
function onTokenGenerating(theUserSession, theAuthorizationRequestDetails) {
theAuthorizationRequestDetails.addAccessTokenClaim('claimName', 'claimValue');
}
The onTokenGenerating callback executes after the user has approved any scopes, but before the user's session is actually created. The callback can examine the approved scope list, as well as modifying it. The following example shows several scope manipulation methods.
/**
* This function is called just prior to the creation and issuing of a new
* access token.
*
* @param theUserSession The authenticated user session (can be modified by the script)
* or null if a client is authenticating as opposed to a user
* @param theAuthorizationRequestDetails Contains details about the authorization request
*/
function onTokenGenerating(theUserSession, theAuthorizationRequestDetails) {
// Log the list of approved scopes
Log.info("The following scopes have been approved for the session: " + theUserSession.approvedScopes);
// Remove an approved scope and add a different one
theUserSession.removeApprovedScope('patient/*.read');
theUserSession.addApprovedScope('patient/Observation.read');
}
The onPostAuthorize function is invoked any time that an authorization has succeeded. This function is invoked after the token is generated and should not be used to cancel or modify the authorization.
This function takes one argument that is named theDetails
by convention. This argument is of type SmartOnPostAuthorizeDetails.
/**
* This function is invoked after a successful OAuth2 authorization flow. It
* will be passed details about the access token and the request. It may be used
* to log theDetails or call third-party systems. It should not try to abort the
* grant as a token has already been issued at the time that this method is
* called.
*
* @param theDetails This object contains information about the grant
*/
function onPostAuthorize(theDetails) {
/*
* Here we will just log relevant details
*/
// Log the grant type, e.g. "implicit" or "refresh"
Log.info("Successful completion of grant type: " + theDetails.grantType);
// Log the granted scopes
Log.info(" * Authorized scopes: " + theDetails.grantedScopes);
// For a Cross-Organization Data Access Profile request, the "requesting
// practitioner" and "requested record" (typically a FHIR Practitioner and
// Patient resource, respectively) will be populated. These will be null
// for other grant types.
Log.info(" * Requesting practitioner: " + theDetails.requestingPractitioner.identifier.value);
Log.info(" * Requested record: " + theDetails.requestedRecord.id);
}
See OAuth2 Exceptions for details on how to throw exceptions within this method.
This function applies only to the CODAP Grant type described above. It is placed in the CODAP Authorization Script setting on SMART Outbound Security module.
This function is used to process the user request and return either a successful or a failed authorization outcome.
Inputs:
theRequest
– This object contains details about the request, including the authorization and authentication tokens. This object is of type SmartCodapAuthorizationRequest.
theOutcomeFactory
– This object is used to create a success or failure object to be returned by the function. This object is of type ScriptAuthenticationOutcomeFactory.
Output:
/**
* This method is called when an authorization is requested, BEFORE the
* token is created and access is granted
* @param theRequest The incoming theRequest
* @param theOutcomeFactory This object is a factory for a successful
* response or a failure response
* @returns {*}
*/
function authenticate(theRequest, theOutcomeFactory) {
var outcome = theOutcomeFactory.newSuccess();
// Grab the identity of the requesting user
var practitioner = theRequest.requestingPractitioner;
// We need to set the username in the outcome object. This username should uniquely
// identify the user. In this example, we'll use the FHIR Practitioner.identifier.value
// but this could be something else.
if (!practitioner.identifier.value) {
throw 'Practitioner.identifier.value did not have a value!';
}
outcome.username = practitioner.identifier.value;
// Just to show how this is done, we'll ban the user with the
// given username by rejecting their authorization
if (outcome.username == 'BADUSER') {
var failure = theOutcomeFactory.newFailure();
failure.message = 'Banned username';
failure.incorrectPassword = true;
return failure;
}
// The following contains the patient that was requested
var patient = theRequest.requestedRecord;
// Grant the user access to read and write any resources belonging to
// the requested patient
outcome.addAuthority('FHIR_READ_ALL_IN_COMPARTMENT', patient.id);
outcome.addAuthority('FHIR_WRITE_ALL_IN_COMPARTMENT', patient.id);
return outcome;
}