shinyOAuth allows your Shiny app to request access to a FHIR server using SMART on FHIR, read data for the selected patient, and keep separate authorizations for different healthcare providers.
This is useful for a patient dashboard that reads records from a hospital, or a clinical app that opens from an electronic health record (EHR) with a patient already selected.
This vignette builds an app that connects to a hospital and reads a Patient resource. It also shows how to search for Observations, identify the logged-in user, and configure the app to launch from an EHR.
FHIR and SMART on FHIR
FHIR defines healthcare data structures and ways to
exchange them. A Patient resource describes a patient; an
Observation can describe a clinical measurement. An address
such as https://ehr.example/fhir/R4 is the base of a FHIR
API. See the FHIR
overview.
OAuth lets an app obtain permission to call an API. SMART on FHIR specifies how to use OAuth with FHIR: discover authorization endpoints, request healthcare permissions, identify the intended FHIR server, and obtain launch context such as the selected patient. The EHR can open the app, or the person can open it independently. See SMART App Launch.
The example below uses smart_discover() to read the
server’s SMART metadata and smart_client() to configure
your registered app. After the user authorizes access,
smart_patient() reads the selected patient’s record using
the saved connection.
To connect several healthcare providers, pass a named list of clients to the connection manager, as shown in Keep and use multiple OAuth authorizations.
Before running the example
Register the app with the chosen server. Obtain its exact FHIR base,
client ID, authentication method, and permitted scopes. This example
uses a standalone confidential client with a shared
secret (token_auth_style = "header"). A
server-side Shiny deployment can keep that secret outside the
browser.
The example’s .example endpoints are placeholders, not a
running sandbox. Replace them with your approved server’s addresses.
Register http://127.0.0.1:8100/callback/fhir for local
development if your provider permits loopback HTTP, and set
SMART_CLIENT_ID and SMART_CLIENT_SECRET in
your R environment. Use HTTPS and matching registered URLs for
deployment, and omit both allow_http_loopback exceptions
shown below.
This package validates a SMART App Launch STU 2.2 profile, including advertised launch, authentication and scope capabilities. Discovery does not register your app or guarantee that the server will grant its requested access.
Copy the next three code blocks, in order, into
app.R.
1. Discover the server and configure the client
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"
)Discovery runs outside server(), against a
deployment-configured base. endpoint_hosts must include
every approved hostname used by the metadata, including a separate
authorization server when applicable. Its default permits only the FHIR
base’s hostname. Review these addresses and metadata when setting up or
updating the app; do not discover arbitrary URLs supplied by
visitors.
smart_client() configures S256 PKCE, sets
aud to this exact FHIR base, and adds
resource_bases = c(fhir = ...) to the client. You do not
manually build the authorization URL or copy the access token into a
request.
The requested scopes have separate jobs:
| Scope | Why request it? |
|---|---|
launch/patient |
Ask the standalone authorization flow to establish patient context. |
patient/Patient.r |
Read the contextual patient’s Patient resource. |
patient/Observation.s |
Search Observations in the authorized patient context. Optional in this example. |
In SMART v2 syntax, .r means read, .s means
search, and .rs means both. The patient context and the
permission to read data are distinct. The server still decides what
access to allow. See SMART
scopes and launch context.
Here required_scopes allows the app to work if
Observation search is declined. Such a connection is
limited; Patient reading can still succeed. Omitting
required_scopes would make all requested scopes required.
Identity and offline access are not requested by this example.
2. Configure retained connections
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 local configuration name; it is
unrelated to the registered client ID or the patient’s ID. Browser
retention lets the app restore saved grants after authorization
redirects. It uses an HttpOnly owner cookie and encrypted storage in
one R process. Records disappear when that process
restarts; independent workers do not share this store. The multiple-authorizations
vignette explains ownership, expiry and deployment constraints.
3. Authorize and fetch the patient
base_ui <- fluidPage(
use_shinyOAuth(),
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 in a regular browser, connect
the hospital, complete its authorization flow, then load the patient.
The helper reads the Patient identified by the accepted context using
the same connection’s credentials and configured FHIR base. It returns
an httr2 response.
This teaching UI displays only the resource ID. A clinical
application needs appropriate patient identification and must respect
need_patient_banner when provided. Context and resource
bodies contain sensitive data; keep them out of general connection
tables and logs. Key patient-dependent caches by both connection ID and
context[["revision"]], and discard stale data when either
changes.
Search with the same connection
Inside a server observer, use the existing request interface for
other FHIR operations. Add
actionButton("search", "Search observations") to the UI to
use this example:
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 requests FHIR JSON for
resp_body_json(); a FHIR server may otherwise choose XML.
Generic requests retain their existing format defaults.
The patient query selects data; it does not enforce
authorization. The server enforces access, and the connection checks the
declared operation scope. SMART scope comparisons understand supported
combinations such as .rs covering .r and
.s. The package does not infer permissions from arbitrary
HTTP paths. Pagination links must remain inside this connection’s
approved FHIR base.
Granular scopes support simple search parameter names: an optional
leading underscore, then a letter, followed by letters, digits or
hyphens, with nonempty values. This includes _id,
_lastUpdated, _tag, _profile and
_security. Constraints are compared exactly as written; the
package does not decode, reorder or infer implication between searches.
Modifiers, chaining and experimental _filter expressions
remain unsupported.
SMART refresh requests omit scope while permissions
equal the original launch grant. After a reduction, they send the latest
accepted scope limit, including when the server reduces a grant without
rotating the refresh token. Refreshed grants may retain or narrow those
permissions. An explicitly empty grant remains valid, but requires a
fresh authorization to obtain more permissions; an empty OAuth refresh
scope cannot express that limit.
Patient context and user identity are different
smart_context(connection)[["patient"]] identifies the
chart in context. A clinician can be the logged-in user while that
patient is someone else. The browser owner used for local retention is a
third, separate concept.
Identity remains opt-in: the default identity = "none"
does not enable OIDC. For a validated OIDC subject without a FHIR user
reference, use identity = "openid". This requests and
requires only openid, validates the signed ID token and
nonce, and exposes the issuer and subject through
connection[["identity"]](). It leaves
smart_context(connection)[["fhirUser"]] empty.
If the app also needs the authenticated user’s FHIR reference,
configure identity = "fhirUser" when constructing its SMART
client. This adds and requires openid fhirUser, signed
ID-token validation and nonce binding. The server must advertise the
corresponding capabilities and signing metadata.
smart_context(connection)[["fhirUser"]] then exposes the
validated reference.
Fetching that resource with smart_fhir_user(connection)
accepts the granted openid fhirUser identity scopes, as
described by SMART
identity semantics. It also accepts a matching user-level read
permission, or patient/Patient.r when the identity refers
to the contextual Patient. The server enforces its own access rules.
Absolute identity URLs need not use a REST-style resource path. A user
reference outside the configured FHIR base is not fetched with this
token.
Start from an EHR instead
For EHR launch, the EHR opens a registered entry URL carrying
iss (the FHIR base) and an opaque launch
handle. The handle is exchanged through the authorization flow; the
initial URL does not establish trusted patient context. See EHR
launch.
To adapt the standalone app, replace its client construction with:
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"
)Use the credentials for your EHR-launch registration. The constructor
adds launch automatically. You may also request
launch/patient, launch/encounter, or other
launch/ scopes as optional context hints. The EHR may
ignore these hints when that context is unavailable in its current
workflow; see the SMART
EHR context scope guidance. Recreate the manager with this client,
then replace the UI wrapper with:
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
development launch URL, in addition to the separate callback URL. For
deployment register the corresponding HTTPS URLs. Remove the Connect
button and its observer: a fresh EHR launch now starts authorization
automatically. Keep the remaining server code and the
uiPattern = ".*" app construction.
The route selects only the configured hospital client by
its exact FHIR base; it does not discover a new server from the incoming
iss. Each attempt uses a fresh launch transaction. Calling
health[["connect"]]("hospital") for an EHR client reports
fresh_ehr_launch_required; reconnect by opening the app
from the EHR. Keep launch handles out of shared client configuration and
access-log queries.
Each browser owner can hold up to eight unconsumed launch tickets across all manager routes; the manager retains at most 1,000 in total. Extra entries are rejected without removing existing tickets. Consumption or expiry frees space. Protect both launch entry and owner-creating application pages with ingress rate limits at a trusted reverse proxy. Browser cookies do not identify a person or network client, and callers can obtain additional owners, so these quotas do not replace those deployment controls. Use the proxy’s verified client address for rate limits and size the owner registry for the admitted traffic.
EHR launch requires top-level browser navigation, browser retention with a Lax owner cookie, and one R process. Embedded iframe launch is not supported. A route can allow multiple named clients with distinct FHIR bases. Separate registrations for the same base need separate entry routes.
More than one hospital
Create one reviewed discovery snapshot and
smart_client() per registration, then pass
list(hospital_a = client_a, hospital_b = client_b) to the
manager. Use distinct registered callbacks and configure every client
with authorization_server_mode = "multi_redirect_uri" plus
the full callback vector, as in the general
vignette. The selector and request code stay the same. Each
connection carries its own credentials, FHIR base and context; patient
IDs from different bases are not interchangeable. To support both
standalone and EHR entry, configure separate clients for those
flows.
Other registration and refresh options
Choose authentication to match 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 example, replace the original client construction with this for an asymmetric standalone registration. Register the matching public key and key ID with the server first; keep the private key on the application server.
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; app code does not assemble JWTs. See SMART asymmetric client authentication.
If your use case needs access when the user is offline, request
offline_access only with an appropriate registration and
advertised support. Saving a browser connection by itself does not
request offline access or a refresh token. The manager refreshes when a
refresh credential is available. SMART refresh responses must include a
positive expires_in. For initial responses, SMART
recommends that field but permits its omission. If the authorization
server supplies the initial lifetime out of band, configure
smart_client(initial_expires_in_fallback = 300) with that
documented value in seconds. It is used only when the initial response
omits expires_in; an explicit value takes precedence. The
default NULL requires an explicit response lifetime, and
generic OAuth lifetime options do not apply to SMART.
connection[["refresh"]](scopes = ...) can narrow the
connection’s accepted scopes. That local limit is retained and sent on
later refreshes; it does not prove the server has reduced the original
refresh-token grant. Broader access requires a new authorization. On
refresh, read smart_context() again and use its revision to
invalidate stale patient data. Context tracking here does not subscribe
to live changes in the EHR’s open chart.
Outgoing authorization_method = "POST" is available when
discovery advertises authorize-post. It is independent of
response_mode = "form_post", which controls the returning
callback. The default example uses GET authorization and query
callbacks. SMART composition with JAR, PAR, JARM, DPoP and mTLS is not
currently exposed through smart_client(); consult its
reference for scope.