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.
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.
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.
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.
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.
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.
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().
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.
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 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.