--- title: "Use SMART on FHIR from Shiny" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Use SMART on FHIR from Shiny} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## 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](https://hl7.org/fhir/R4/overview.html) and [SMART App Launch](https://hl7.org/fhir/smart-app-launch/STU2.2/app-launch.html). ## 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](usage.html#posit-connect-and-connect-cloud). 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. ```{r smart-configuration, eval = FALSE} 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. ```{r smart-manager, eval = FALSE} 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](multiple-authorizations.html#keep-authorizations-when-the-browser-returns) for retention and deployment choices. ### 3. Authorize and fetch the patient ```{r smart-app, eval = FALSE} 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()`. ```{r smart-search, eval = FALSE} 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](#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()](https://lukakoning.github.io/shinyOAuth/reference/smart_context.html); 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: ```{r smart-ehr-client, eval = FALSE} 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: ```{r smart-ehr-ui, eval = FALSE} 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()](https://lukakoning.github.io/shinyOAuth/reference/smart_launch_route.html). ## 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](multiple-authorizations.html#configure-clients-outside-server). 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. ```{r smart-asymmetric, eval = FALSE} 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](https://hl7.org/fhir/smart-app-launch/STU2.2/client-confidential-asymmetric.html). 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.