Use SMART on FHIR from Shiny

FHIR and SMART on FHIR

FHIR describes healthcare data and how apps exchange it. A Patient resource describes a patient; an Observation can describe a measurement. A FHIR API is the service your app calls to read or search those records.

SMART on FHIR defines how an app requests permission to use that API and receives context such as the selected patient. It also tells the app where users sign in. A person can open the app directly, or an electronic health record (EHR) can launch it with a chart already selected.

Use this guide to connect a hospital and read a patient’s record. Start with the standalone example, then add searches, user identity or EHR launch as needed. For the underlying standards, see the FHIR overview and SMART App Launch.

Before running the example

Register the app with the server you intend to use. Obtain its FHIR base URL, client ID, authentication method and permitted scopes. Scopes are permission names: this example needs permission to read the selected Patient and optionally search Observations. It uses a shared secret kept on the Shiny server.

The .example addresses below are placeholders. Replace them with approved server addresses and register http://127.0.0.1:8100/callback/fhir for local development if the provider permits it. Set SMART_CLIENT_ID and SMART_CLIENT_SECRET in your R environment.

For deployment, register matching HTTPS URLs and remove both allow_http_loopback exceptions. On Posit Connect or Connect Cloud, test at the direct app URL. Copy the next three code blocks into app.R, in order.

1. Discover the server and configure the client

Discovery reads the server’s published login settings. Configure it at startup with a reviewed FHIR base and approved endpoint hosts, so visitors cannot make your app discover arbitrary servers.

library(shiny)
library(shinyOAuth)

app_origin <- "http://127.0.0.1:8100"
discovery <- smart_discover(
  "https://ehr.example/fhir/R4",
  endpoint_hosts = c("ehr.example", "login.example"),
  allow_http_loopback = TRUE # Enable the registered local HTTP callback for this demo.
)

client <- smart_client(
  discovery,
  client_id = Sys.getenv("SMART_CLIENT_ID"),
  client_secret = Sys.getenv("SMART_CLIENT_SECRET"),
  token_auth_style = "header",
  redirect_uri = paste0(app_origin, "/callback/fhir"),
  launch = "standalone",
  scopes = c("launch/patient", "patient/Patient.r", "patient/Observation.s"),
  required_scopes = "patient/Patient.r",
  label = "My hospital"
)

smart_client() prepares authorization for this FHIR base and configures the approved API destination. Request helpers obtain credentials from the connection.

Scope Purpose
launch/patient Ask the standalone flow to select a patient.
patient/Patient.r Read that patient’s Patient resource.
patient/Observation.s Search Observations for that patient.

Here .r means read and .s means search. required_scopes lets the app keep working if optional Observation access is declined.

2. Configure retained connections

Browser retention keeps the authorization available when the browser returns from the hospital’s login page.

manager <- oauth_connections(
  clients = list(hospital = client),
  app_origin = app_origin,
  retention = "browser",
  owner_policy = oauth_browser_owner(allow_http_loopback = TRUE),
  store = oauth_connection_store_memory(),
  # Temporary keys for this local demo, created once at app startup.
  # For deployment, load independent 32-byte raw keys from secret storage.
  keys = list(
    credentials = openssl::rand_bytes(32),
    owner = openssl::rand_bytes(32)
  )
)

hospital is your app’s name for this configuration. The cookie identifies saved browser authorizations, not a person. This memory store keeps encrypted credentials in one R process and loses them on restart. See Multiple authorizations for retention and deployment choices.

3. Authorize and fetch the patient

base_ui <- fluidPage(
  h2("FHIR patient example"),
  actionButton("connect", "Connect hospital"),
  selectInput("connection_id", "Saved authorization", choices = character()),
  actionButton("load_patient", "Load patient"),
  actionButton("disconnect", "Disconnect selected"),
  textOutput("patient"),
  textOutput("auth_error")
)
ui <- oauth_connections_ui(base_ui, "health", manager)

server <- function(input, output, session) {
  health <- oauth_connections_server("health", manager)
  observeEvent(input[["connect"]], health[["connect"]]("hospital"))

  observe({
    rows <- health[["connections"]]()
    ids <- vapply(rows, function(x) x[["connection_id"]], character(1))
    labels <- vapply(seq_along(rows), function(i) {
      paste(rows[[i]][["client_label"]], i, paste0("(", rows[[i]][["status"]], ")"))
    }, character(1))
    selected <- isolate(input[["connection_id"]])
    if (!length(selected) || !selected %in% ids) selected <- head(ids, 1)
    updateSelectInput(session, "connection_id",
      choices = setNames(ids, labels), selected = selected)
  })

  selected_connection <- reactive({
    rows <- health[["connections"]]()
    ids <- vapply(rows, function(x) x[["connection_id"]], character(1))
    req(input[["connection_id"]], input[["connection_id"]] %in% ids)
    connection <- health[["connection"]](input[["connection_id"]])
    req(connection[["is_usable"]]())
    connection
  })

  patient <- eventReactive(input[["load_patient"]], {
    health[["touch"]]()
    connection <- selected_connection()
    context <- smart_context(connection)
    data <- tryCatch({
      response <- smart_patient(connection)
      httr2::resp_check_status(response)
      httr2::resp_body_json(response, simplifyVector = FALSE)
    }, error = function(e) NULL)
    list(connection_id = connection[["id"]], revision = context[["revision"]], data = data)
  })

  output[["patient"]] <- renderText({
    connection <- selected_connection()
    context <- smart_context(connection)
    value <- patient()
    # Clear the display if the selected grant or accepted context has changed.
    req(identical(value[["connection_id"]], connection[["id"]]),
        identical(value[["revision"]], context[["revision"]]))
    validate(need(!is.null(value[["data"]]), "Could not load this patient's record."))
    paste("Patient resource:", value[["data"]][["id"]])
  })
  observeEvent(input[["disconnect"]], {
    # Allow disconnect even when the connection has become unusable.
    req(input[["connection_id"]])
    health[["disconnect"]](input[["connection_id"]])
  })
  output[["auth_error"]] <- renderText({
    if (length(health[["errors"]]())) "Authorization is unavailable. Try authorizing again."
  })
}

runApp(shinyApp(ui, server, uiPattern = ".*"),
  host = "127.0.0.1", port = 8100, launch.browser = FALSE)

Open http://127.0.0.1:8100, connect the hospital, complete authorization and load the patient. smart_patient() reads the Patient selected by the accepted context, using that connection’s credentials and FHIR base.

The example displays only the resource ID. need_patient_banner, when supplied, carries the EHR’s patient-identification requirement for the clinical UI.

The output checks both connection ID and context revision, so changing the authorization or accepted patient context removes the previous result.

Search with the same connection

Add actionButton("search", "Search observations") to the UI and this observer inside server().

observeEvent(input[["search"]], {
  health[["touch"]]()
  connection <- selected_connection()
  context <- smart_context(connection)
  req(context[["patient"]])
  tryCatch({
    response <- connection[["request"]](
      "fhir", "Observation",
      query = list(patient = context[["patient"]], `_count` = 10),
      required_scopes = "patient/Observation.s",
      configure = function(req) httr2::req_headers(req, Accept = "application/fhir+json")
    )
    httr2::resp_check_status(response)
    bundle <- httr2::resp_body_json(response, simplifyVector = FALSE)
    # Process bundle[["entry"]] here, associating data with this ID and revision.
    showNotification("Observation search completed.")
  }, error = function(e) {
    showNotification("Could not search observations. Check the granted permissions.",
      type = "error")
  })
})

The Accept header asks for FHIR JSON. The connection rejects pagination links outside its approved FHIR base. The Patient example shows how connection ID and context revision associate saved results with the accepted context.

See granular permissions and refresh for scope syntax and refresh behavior.

Patient context and user identity are different

The selected patient may be someone other than the person who signed in, such as a clinician viewing a patient’s chart. smart_context(connection)[["patient"]] identifies the chart context; it does not identify the logged-in user.

identity = "openid" validates the signed-in user’s identity; identity = "fhirUser" also requires that user’s FHIR reference. Both depend on the server’s advertised support. The default, identity = "none", requests API access without identity verification.

Read selected identity fields with connection[["identity"]](). For a FHIR user reference, use smart_context() and smart_fhir_user(); it only fetches within the configured FHIR base.

Start from an EHR instead

An EHR can open your app with its FHIR base (iss) and a temporary launch handle. The route starts authorization with this handle; accepted patient context comes from the authorization response. This supports a workflow where the user starts from the chart rather than choosing a hospital in your app.

Replace the standalone client with the EHR registration:

client <- smart_client(
  discovery,
  client_id = Sys.getenv("SMART_EHR_CLIENT_ID"),
  client_secret = Sys.getenv("SMART_EHR_CLIENT_SECRET"),
  token_auth_style = "header",
  redirect_uri = paste0(app_origin, "/callback/fhir"),
  launch = "ehr",
  scopes = c("patient/Patient.r", "patient/Observation.s"),
  required_scopes = "patient/Patient.r",
  label = "My hospital"
)

Recreate the manager with that client, then replace the UI wrapper:

ui <- oauth_connections_ui(base_ui, "health", manager,
  launch_routes = list(smart_launch_route("/smart/launch", "hospital")))

Register http://127.0.0.1:8100/smart/launch as the local launch URL in addition to the callback. Use corresponding HTTPS URLs in deployment. Remove the Connect button and observer; opening the app from the EHR now starts login. The remaining server code and uiPattern = ".*" stay the same.

EHR launch supports top-level browser navigation with browser retention, a Lax owner cookie and one R process. Embedded iframe launch is unsupported. A fresh launch from the EHR starts reauthorization. The route accepts only configured FHIR servers, rather than discovering a server from an incoming URL.

For route limits and proxy requirements, see smart_launch_route().

More than one hospital

Create a discovery result and client for each registration, then pass list(hospital_a = client_a, hospital_b = client_b) to the manager. Use the distinct callbacks and complete callback vector shown in Multiple authorizations.

Each connection has its own FHIR base, credentials and context. Patient IDs from different hospitals are not interchangeable. Standalone and EHR-launch entry for the same server use separate clients.

Other registration and refresh options

The client authentication method comes from the server registration:

Registration smart_client() settings
Public token_auth_style = "public"; omit the secret and assertion key.
Confidential, shared secret token_auth_style = "header" and client_secret.
Confidential, asymmetric token_auth_style = "private_key_jwt", private key, key ID, and supported RS384 or ES384.

For an asymmetric standalone registration, replace the original client with this configuration. The public key and key ID belong to the server registration; shinyOAuth signs assertions with the matching private key.

client <- smart_client(
  discovery,
  client_id = Sys.getenv("SMART_ASYMMETRIC_CLIENT_ID"),
  redirect_uri = paste0(app_origin, "/callback/fhir"),
  launch = "standalone",
  scopes = c("launch/patient", "patient/Patient.r"),
  required_scopes = "patient/Patient.r",
  token_auth_style = "private_key_jwt",
  client_assertion_private_key = openssl::read_key(Sys.getenv("SMART_PRIVATE_KEY_FILE")),
  client_assertion_private_key_kid = Sys.getenv("SMART_KEY_ID"),
  client_assertion_alg = "RS384",
  label = "My hospital"
)

The server must advertise the chosen method and algorithm. The package creates and signs the assertion. See SMART asymmetric client authentication.

The offline_access scope requests longer-lived access when supported by the server and registration. When a refresh token is issued, the manager can renew access.

SMART refresh responses need a positive expires_in. If the initial response omits it, set initial_expires_in_fallback only to a lifetime supplied by the server out of band. The default NULL requires an explicit lifetime; generic OAuth lifetime options do not apply.

connection[["refresh"]](scopes = ...) can reduce locally accepted permissions. Broader access requires new authorization. After refresh, read smart_context() again and invalidate patient data when its revision changes. This tracks accepted authorization context, not live changes in the EHR’s open chart.

Outgoing authorization_method = "POST" requires advertised authorize-post support. It is separate from the returning callback’s response_mode. smart_client() does not expose JAR, PAR, JARM, DPoP or mTLS composition.

Granular permissions and refresh

Granular scopes can restrict simple search parameters such as _id, _lastUpdated, _tag, _profile and _security. Constraints match literally; modifiers, chaining and experimental _filter expressions are unsupported.

Refresh can retain or narrow the grant. After a reduction, shinyOAuth keeps and sends the latest accepted limit, even if the refresh token did not rotate. An empty grant cannot gain permissions again without a new authorization.