SMART Part 3: The launch sequence

There are a lot of moving parts in the SMART-on-FHIR game, but the launch sequence is where the magic really happens. The launch sequence provides your SMART app with automated login as well as user, patient and encounter context — everything necessary to create a friction-free and natural user experience. In this installment of the SMART on FHIR saga, we’ll walk through the process in detail.

Of course, the whole reason I wrote SmartEhr/SmartServer was to handle stuff like this for you. If I’ve been successful, then this article may not be that relevant. Still, it’s always useful to know what’s going on under the covers, and hopefully Google will leave a trail here for folks struggling to build their own implementations.

The Big Picture

SMART launch is actually covered quite well in the official documentation. It’s a bit broad for our purposes though (we only care about “EHR Launch”) and leaves out a bunch of implementation-dependent quirks that can easily trip things up. In short, EHR Launch proceeds like this:

  1. Within the EHR user interface, the user navigates to a SMART app using a link or button set up by the EHR administrator. This opens up a web browser, typically within an iframe. The browser is directed to a pre-configured “launch” URL hosted by the SMART app with some magic parameters appended to the query string.
  2. Upon receiving this request, the SMART app confirms the source EHR that initiated the sequence, then redirects the browser back to the EHR with query string parameters that confirm the identity of the SMART app and request access to the specific data types required.
  3. Next the EHR performs final verification. In some launch scenarios the EHR also asks the user (through UX shown in the iframe) to login and/or select a target patient — but this does not happen with our embedded “EHR Launch” scenario, because that context is already set up (kind of the point of all this). The EHR redirects the browser one more time to the SMART app, adding a short-lived authorization code to the query string.
  4. The SMART app makes a back-end request (independent of the browser) to the EHR, exchanging the authorization code for an “access token” and user/patient context.
  5. Finally, the SMART app renders its user experience in the iframe created so very long ago. The app retrieves EHR data using the access token and FHIR resource URLs, and does what it does. Excepting refresh token management, that’s where the story ends, with happy users and hopefully some innovative care workflows!

For those that need a little more jargon in their lives, all this is basically an OAuth2 exchange. We’re going to dive into each step in much more detail below … buckle up!

Setup: URLs

The security of a SMART app connection depends on each system knowing where the other one lives. In step #3 above, the EHR will only send the authorization code to a “return URL” that is preconfigured for the app. Each EHR system has its own interface for managing this configuration; we walked through the Epic and Cerner flavors in the previous article in the series. Once you’ve seen one, the others will feel very familiar.

Similarly, the SMART app needs to know which EHRs are trusted and where they live. Each app can do this however they like; for SmartEhr we use a JSON configuration file that contains one entry for each trusted EHR. For example:

{
    "SiteId": "EpicSandbox",
    "IssUrl": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
    "ClientId": "3b0e5c6a-e5df-438f-b654-864aed042ce0"
}

The “IssUrl” is the FHIR base URL for the relevant EHR instance; it must match the “iss” parameter that comes in on the “launch URL” we’ll talk about later. These two intertwined settings provide the basis for secure exchange of information. Hopefully it goes without saying that these need to be trusted HTTPS URLs for it to work as intended, but hey I’ll say it anyways.

The “ClientId” is assigned to the SMART app by the EHR, and also must be present in order for the exchange to work properly — you’ll find it in the same portal you use to set your return URL and such.

A couple of things to note about site configuration in SmartEhr. First, it is conceivable that you might have two EHRs using the same IssUrl setting — for example in a cloud-hosted multitenant situation. In that case, the EHR “launch” URL must be configured to also pass a “siteid” parameter that matches an entry in the SmartEhr configuration (SiteId values are arbitrary but should be unique). This does not change the requirement that the IssUrl value must match what we see on the launch URL; it simply allows us to differentiate between multiple tenants.

Second, the IssUrl does double-duty. It is the FHIR base URL for the EHR — that is, the root of the URL that we will use to query for data about the patient. But it also hosts an endpoint that contains “metadata” information about the EHR’s capabilities. Most importantly, this metadata contains two additional URLs: The “authorization” URL is the basis of the redirect in Step #2 above, and the “token” URL is used to fetch (and refresh if allowed) access tokens in Step #3.

SmartEhr pre-loads these URLs on a per-IssUrl basis at startup. By default we use the “/metadata/” well-known endpoint for this purpose. An alternative mechanism using the endpoint “.well-known/smart-configuration.json” can be selected by including the value “UseWellKnownMetadata” = true in a site configuration; there isn’t really any advantage to one or the other. If for some reason an EHR doesn’t support either of these endpoints, the URL values can be hardcoded in the site configuration with the values “AuthUrl” and “TokenUrl”.

Setup: Scope

As with the overall launch sequence, there’s pretty good documentation for launch scopes on the HL7 site. It’s just that there is a LOT there and it can be hard to navigate, so we’ll add a bit of color commentary.

Scopes are an OAuth2 concept that enable granular access to a user’s data; each scope is an application-defined string that represents some logical subset. In the FHIR case, an example is “user/Condition.Read” which is the scope required to look at (but not modify) a condition record for any patient that the EHR user has access to.

In many typical OAuth2 scenarios, the logged-in user is presented with a list of the scopes needed by an application at runtime, and explicitly grants or denies access to them. (They can even choose to grant some of the requested scopes but not all — a seemingly nice bit of flexibility that in reality just causes most apps to fall on their face.)

Our EHR launch scenario is a little different, in that the scopes needed by the SMART app are pre-configured up front on the EHR’s developer site. Theoretically an EHR could still show these values to the user for approval, but in my experience that doesn’t happen. This is actually much nicer (in this specific scenario) for the actual EHR user, but does create some redundant noise in the protocol that’s a little annoying.

These are the key scopes that you’ll likely run into:

ScopeNotes
launchRequired for the EHR launch scenario, this scope gives you access to the “context” of the EHR — who is the patient and what is their current encounter.
openid fhirUser user/Practitioner.readProvides the identity (in the form of a FHIR resource) of the logged-in user (openid fhirUser) and the rights to retrieve that resource (user/Practitioner.read). This is important if, for example, you need to know the user’s email address or NPI number.
patient/Patient.readDemographic details for the in-context patient. Replace “Patient” after the slash with other resource names as needed (e.g., Condition, MedicationRequest).
user/Patient.readAs above (including the use of other resource names), but granting access to all resources of that type available to the user (i.e., across many patients).
[patient/user]/[Resource].writeEach of the “.read” scopes has a corresponding “.write” version. I haven’t focused on these in SmartEhr so far, but it’s a simple extension and perhaps a good blog post someday.
[patient/user]/[Resource].searchUsing the same pattern as for read, the “.search” suffix allows parameterized queries for resources. I find this one annoying, because the official documentation says it should just be read and write to encompass both actions, but we live in the world as it is.

The patient/… and user/… scopes technically support wildcard syntax (e.g., patient/*.* to read and write everything about the in-context patient). A useful concept, but somewhat frowned upon even in the official documentation — and Cerner doesn’t even implement it. So despite the mess that comes with lengthy scope strings, better to avoid the wildcards.

Frankly getting these scopes right, and in sync with the EHR’s developer portals, can be tedious and a bit frustrating. For example, it took me longer than it should have to figure out that to get a list of conditions for a patient, Cerner wants “Condition.read” while Epic wants “Condition.search”. Combine this with the fact that there is often a time lag (of indeterminate length) between editing your configuration and having it apply in the sandbox and, well, ugh.

Another example: in the R4 API version Epic has broken down Conditions into different types (Problems, Health Concern, Genomics, etc.), and will fail searches that don’t include parameters to filter to what the app has access to. Why? I can understand adding granularity to the permissions model, but then just return the records the app can see. Healthcare standards, man.

SmartEhr always sends “launch openid fhirUser” in the scope string. Additional scopes are added based on the value of the “Scope” parameter in the json configuration file.

Refresh Tokens

Less common, but important scopes if you will have long-running sessions, are “online_access” and “offline_access”. These both ask to enable “refresh tokens” for long-lived access to EHR data. In my experience, EHR vendors are (understandably) not super-hot on this concept, but it can be useful and it is implemented in SmartEhr. Refresh behavior basically works like this:

  • App configuration at the EHR developer site must indicate a need for refresh tokens. This will also require the app to have a “Client Secret” which is kept private (in the case of SmartEhr, in the site configuration json).  
  • The “online_access” or “offline_access” scope is provided during launch, indicating to the EHR (ahem, again) that you need a refresh token. The difference is that “online_access” provides a refresh token that is valid only as long as the currently logged-in EHR user stays logged in. “offline_access” persists indefinitely (more or less).
  • The app will receive a refresh token in addition to the access token in Step #4 of the “Big Picture” described at the top of this article.
  • If the access token expires, a new one can be fetched by sending the refresh token and the client secret to the EHR’s “TokenUrl” endpoint. Successful return will also include another refresh token so the app can rinse and repeat as needed.

“online_access” seems like a great idea for an app that stays interactive with the user, because otherwise there’s no way to ensure synchronization with the EHR’s login timeout. But in my experience, implementation is really spotty and I’ve literally been told by one vendor to “just not worry about it.” It’s true that the access token you’re granted generally will outlast a typical session, so that’s not crazy, I guess. Feels messy.

“offline_access” is perfect for surveillance apps that will periodically look for new data, or for writing data back to the EHR that may come later — for example, the user may ask for some long-running computation and have the results written back as a PDF document.

Once things are set up in the EHR developer portal, you can enable refresh tokens in SmartEhr by adding the appropriate values to the json configuration. For example at lines 3 and 9 below:

{
    "Smart": {
        "Scope": "... [on/off]line_access",
        "Sites": [
            {
            "SiteId": "EpicSandbox",
            "IssUrl": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
            "ClientId": "3b0e5c6a-e5df-438f-b654-864aed042ce0",
            "ClientSecret": "SECRET_SHARED_WITH_EHR"
            }
        ]
    }
}

The Launch URL

I can’t believe we’re this far into this and haven’t even actually started the launch. But we’re here at last. All the code for this part is in SmartEhr.launch; it might be helpful to have that open to refer to along the way.

Looking back at the “Big Picture,” Step #1 starts with the EHR sending a browser to the app’s launch URL as configured in the EHR developer portal — this is what we’ve simulated in the last two articles using the various EHR sandbox links. The EHR adds two query string parameters to this URL:

  • iss: the FHIR base URL for the EHR, corresponding to the “IssUrl” values in the json config.
  • launch: an opaque (to the app) string that identifies the EHR session and context.

The first thing we do is to allocate a new Session that we’ll maintain throughout the app’s lifecycle. It starts out pretty empty, but great things are in store for it.

Next, we need to find the correct SiteConfig for this EHR. Back in the Setup section we talked about the optional “siteid” query string parameter. We’ll use that value to look up the SiteConfig if it’s present, or if not will use the “iss” parameter. Either way, we succeed only if we can identify exactly one matching config and the provided “iss” query string parameter matches our configured value for the site.

Finally, we need to build up a URL where the EHR will confirm all is well before giving us the keys to get an access token. We pile a ton of query string parameters onto this URL:

  • response_type: always the string “code”.
  • aud: always (for our scenario) the “IssUrl” value in the SiteConfig.
  • client_id: the “ClientId” value in the SiteConfig.
  • redirect_uri: The URL that we would like the EHR to send the browser back to after confirming things are OK. This must match the value pre-configured in the EHR developer portal.
  • launch: the same value we received on the query string from the EHR. Round-tripping this value lets the EHR associate our request with the initial launch.
  • state: our growing “Session” object, dehydrated into a string. Just as “launch” helps the EHR keep track of context through this negotiation, “state” lets us do the same and we’ll see it again later. It’s important to keep tabs on how big this value is — as I found out the hard way, beyond 4k some browsers (and EHRs) will puke.  
  • scope: as described in the “scopes” section, a space-separated string containing all of the scopes the app requires to run. I find this frustrating — the EHR already knows what scopes we need because we configured it back at their developer portal. It makes sense for other launch types, but for us it just means keeping two lists in sync for no real benefit. Ah well.

The launch method returns this complete URL to the caller to execute the redirect. Note that throughout all of this, “SmartEhr” is the headless engine that can be used within any web framework. If you’d like to see how that engine is used in an actual web context, take a look at SmartServer, in particular for this stuff SmartServer.registerLaunchHandler.

Successful Authorization

After the EHR decides that everything we just sent is OK, they send the browser back to the app’s return URL (the “redirect_uri” as configured in the EHR developer portal and sent in the redirect_uri query string parameter above). The EHR adds two parameters to this URL:

  • code: a short-lived token that we’ll trade for our real goal, an access token.
  • state: The same value we sent to the EHR in the last redirect.

The code for this phase is in SmartEhr.successfulAuth. We first “rehydrate” the Session using the state parameter, which provides the link back to the correct site configuration. Next up is a backend call (i.e., not using browser redirects) to the EHR to swap “code” for an access token. This call is a POST to the “TokenUrl” value in the SiteConfig, including the following form elements (in normal application/x-www-form-encoded format):

  • grant_type: always “authorization_code”
  • code: the code parameter we received on the query string
  • redirect_uri: the same redirect_uri we sent in the last phase. Don’t really get this one, but hey.

If we’re going to request refresh tokens, we send this request with a Basic Authorization header where the “user” is the configured ClientId and then “password” is the ClientSecret. If not, we just append ClientId to the query string as client_id.

This is the first time we’ve made a direct request from our app to the EHR, rather than going through the browser. For all of these, be sure to set the “Accept” header of your requests to “application/json”. Without it, Epic will return results in XML and Cerner won’t return anything at all. To be fair, this is buried in the spec, but come on now.

There is a ton of great stuff in the payload returned by this call; SmartEhr sifts it out in SmartEhr.parseTokens. For starters it includes the basic authorization stuff: the access token, an expiration timestamp for the token, and if appropriate a refresh token. We also get an identifier for the in-context patient, and a Boolean value “need_patient_banner” that you shouldn’t ignore. If this value is true, it means the app’s web page is not embedded in the EHR with visible patient context, so the app should prominently display the patient’s name and possibly other demographics such as birthdate. Users engaged in clinical care move quickly; accidentally working with the wrong chart can have disastrous results so this is a real safety issue — make sure your app is a good citizen!

The last piece of data we extract from this payload is the FHIR Resource identifier (a relative URL under the IssUrl) for the logged-in user. This value is buried in the “id_token”, which is a JSON Web Token string. There are a few steps to decoding this value, but ultimately we pull the value out of the “fhirUser” value in the token json. I’ve found EHRs to be inconsistent in returning relative or absolute URLs in this value; SmartEhr normalizes it so developers can count on a consistent format.

All of this goodness gets stored in the Session object that is then returned from the successfulAuth method. It’s important that this object be available in each request made by the logged-in user’s browser, so it needs to be stored in some kind of session manager. SmartServer accomplishes this in the registerReturnHandler method by storing the dehydrated Session in a session cookie, but whatever your web environment uses for session management is fine.

With an in-memory session manager, you can just store the object itself and be done with it. However, in production it’s more likely that you’ll use some sort of session that works across machines (the cookie that SmartServer uses is one example of this). In that case, you’ll need to be aware that the Session contents may change during a request — for example, if the access token expires and is refreshed. Each request that uses the Session should be wrapped with something like this:

String sessionData = getSessionDataFromMySessionManager();
SmartEhr.Session session = mySmartEhrInstance.rehydrate(sessionData);

// code does stuff with session

sessionData = mySmartEhrInstance.dehydrateIfUpdated(session);
if (sessionData != null) storeSessionDataInMySessionManager(sessionData);

You can see an example of this happening in the implementation of SmartServer.SessionHandler. SmartServer apps just inherit their handlers from SessionHandler and things are managed behind the scenes. Whatever web framework you use almost certainly has a similar mechanism to add pre- and post-actions to your handlers.

This seems a good time to reinforce that the SmartEhr object itself wants to be a long-lived process singleton, rather than something created on each request. This comes down to all of the metadata requests that happen on creation of the object. The singleton nature isn’t enforced, but maybe it should be. I’ll think about that.

Apps doing App Things

Anyways — at last, the app can just be an app. Each request has access to an authorized Session object, and can use it together with the global/singleton SmartEhr object to request FHIR data in whatever way and pattern makes sense.

I’ll be honest, I can’t imagine that anyone actually has read this far. That’s fine — the point here was to be super-comprehensive and call out all the little quirks that make developing from the spec a challenge. Google, please serve it up to those in need.

And I suppose maybe walking through all the grotty details will encourage you to just use SmartEhr, and maybe even SmartServer too. If you do, and especially when you find bugs, let me know!

Next up (and maybe the last, not sure) in the series, we’ll talk about working with FHIR resources and dealing with the challenges of extreme optionality in healthcare standards. Follow me on Twitter or LinkedIn or whatever to get a ping when that one comes out.

*** A last note and request (this will show up at the bottom of each article in the series). I’ve spent a lot of time in this industry, and the systemic impediments to progress and innovation can make even good folks feel hopeless sometimes. I really, truly believe that SMART is one of those rare technologies that has matured at exactly the right time to change the game. But there’s no guarantee — not enough folks know about it, and it’s too hard to use. If you swim in this pool, please help me fix that:

  1. Share these articles with folks that use and implement EHRs. Tell them to look at the “app store” for their system and add an app to their test system. Tell them to ask vendors if they have a SMART interface to their solution.
  2. Share these articles with folks that build care delivery solutions. Explain how they can use SMART to add functionality for customers without a custom login and without having to do an integration project with custom IT teams.
  3. Contact me if I can help. There’s a form here on the website, and I’m @seanno on Twitter, or use LinkedIn, or whatever. I’m happy to answer questions, make some connections, and heck I might even write some code for you if it makes a difference.