Skip to contents

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.