Skip to contents

shinyOAuth allows you to keep multiple OAuth authorizations in one Shiny app. Users can connect several services, choose which saved authorization to use for a request, and disconnect each one separately.

This is useful for a dashboard that combines data from several APIs, or an app where users need access to several accounts at the same service.

This vignette builds an app that connects to two services and reads data from either one. It uses browser retention to keep both authorizations available when the user returns from a provider’s login page.

Client configuration and connections

Create an OAuthClient for each service with its registration details, scopes and API addresses. Put the clients in a named list and pass it to oauth_connections(). Your app uses those names to choose which service to connect when a user clicks a Connect button.

Each successful authorization creates a separate connection. Inside server(), retrieve that OAuthConnection to make requests with its current credentials.

One client can have several connections. Connecting A again creates another authorization alongside the first. Account selection happens at the provider; it may reuse the account already signed in there.

A provider may also return the same refresh credential for both connections. The manager queues overlapping asynchronous refreshes for that credential. If it rotates or its refresh outcome is uncertain, other connections holding the old credential become uncertain and require authorization again. The manager keeps their account identity and permissions separate. Successful revocation also stops use of known copies of the revoked credential within that manager.

For apps that need one authorization per Shiny session, see the oauth_ui() and oauth_module_server() example in Usage.

Register two clients

This template uses two fictional services. Replace their endpoints, credentials, scope names and API path with values from your providers’ documentation. It assumes authorization code flow with S256 PKCE and confidential clients using HTTP Basic client authentication. The providers must support that registration.

Register these exact redirect URIs with the respective services:

Service Redirect URI
A http://127.0.0.1:8100/callback/a
B http://127.0.0.1:8100/callback/b

These loopback HTTP addresses are for local development, if your providers permit them. Use your public HTTPS origin and corresponding registered callbacks when deploying, and omit the owner’s allow_http_loopback exception. Store the two client IDs and secrets in the environment variables used below, outside source control.

Copy the next three code blocks, in order, into app.R.

1. Configure clients outside server()

library(shiny)
library(shinyOAuth)

app_origin <- "http://127.0.0.1:8100"
callbacks <- paste0(app_origin, c("/callback/a", "/callback/b"))

provider_a <- oauth_provider(
  name = "Service A",
  auth_url = "https://login.a.example/authorize",
  token_url = "https://login.a.example/token",
  token_auth_style = "header",
  use_pkce = TRUE,
  pkce_method = "S256"
)
provider_b <- oauth_provider(
  name = "Service B",
  auth_url = "https://login.b.example/authorize",
  token_url = "https://login.b.example/token",
  token_auth_style = "header",
  use_pkce = TRUE,
  pkce_method = "S256"
)

client_a <- oauth_client(
  provider_a,
  client_id = Sys.getenv("SERVICE_A_CLIENT_ID"),
  client_secret = Sys.getenv("SERVICE_A_CLIENT_SECRET"),
  redirect_uri = callbacks[[1]],
  scopes = "records.read",
  resource_bases = c(api = "https://api.a.example/v1"),
  required_scopes = "records.read",
  label = "Service A",
  authorization_server_mode = "multi_redirect_uri",
  authorization_server_redirect_uris = callbacks
)
client_b <- oauth_client(
  provider_b,
  client_id = Sys.getenv("SERVICE_B_CLIENT_ID"),
  client_secret = Sys.getenv("SERVICE_B_CLIENT_SECRET"),
  redirect_uri = callbacks[[2]],
  scopes = "records.read",
  resource_bases = c(api = "https://api.b.example/v1"),
  required_scopes = "records.read",
  label = "Service B",
  authorization_server_mode = "multi_redirect_uri",
  authorization_server_redirect_uris = callbacks
)

clients <- list(a = client_a, b = client_b)

a and b are app-local selectors, independent of the registered client_id. api names an approved resource within each client. For connection A, [["request"]]("api", "records") means https://api.a.example/v1/records.

The default callback policy gives each client a distinct route. Supply the complete callback vector to each client, even though each uses its own redirect_uri. The manager verifies that each client’s declared set includes all of its callback routes, comparing canonical origins and paths. Additional routes used elsewhere in the application may also be included. Shared callback routes are an advanced alternative documented in ?oauth_connections.

2. Keep authorizations when the browser returns

# Temporary keys for this local demo, generated once per R process.
# In a deployment, load two independent 32-byte raw keys from secret storage.
keys <- list(
  credentials = openssl::rand_bytes(32),
  owner = openssl::rand_bytes(32)
)

manager <- oauth_connections(
  clients,
  app_origin = app_origin,
  retention = "browser",
  owner_policy = oauth_browser_owner(allow_http_loopback = TRUE),
  store = oauth_connection_store_memory(),
  keys = keys
)

Browser retention uses an HttpOnly cookie containing an opaque local owner ID. Tokens stay encrypted in the R process. The cookie associates saved connections with this browser; it does not identify a person or log them into either service.

Without retention = "browser", the default is "shiny": existing connections are discarded when that Shiny session ends. Merely configuring multiple clients does not preserve A while the browser goes away to authorize B.

Keep the manager, store and keys outside server() so returning Shiny sessions use the same configuration. This memory store supports one R process and does not survive an R restart. Stable keys alone do not make it persistent or share records between workers. Browser ownership also has idle and absolute expiry limits. Automatic refresh and API reads do not reset inactivity. Call auth[["touch"]]() from a user input event handler, as the Read handler below does, to count an application action as activity. Explicit refresh also counts as activity; the absolute expiry remains fixed. For an app with its own verified account login, see ?oauth_account_owner and retention = "account".

Size the owner registry and credential store separately. Both owner factories accept max_entries (default 1,000) per manager. Retained browser entries count browsers that successfully authorize a service. Recent visitors occupy a separate provisional pool with the same limit; entries without a pending authorization may be replaced and expire after at most five minutes. Pending authorizations protect their browser entries until cancellation or transaction expiry, subject to the owner’s idle and absolute limits. If all provisional entries are protected, new visitors are rejected. Account entries include logged-out login generations until their reauthentication deadline. A full retained registry rejects new retained owners while preserving existing retained sessions and retirement records. oauth_connection_store_memory(max_entries = ...) controls a different limit: successful authorization transactions occupy capacity for the store’s full max_age, including after a connection is disconnected. Neither limit measures only the number of currently open Shiny sessions.

3. Select a connection and make a request

base_ui <- fluidPage(
  use_shinyOAuth(),
  h2("My connected services"),
  actionButton("connect_a", "Connect Service A"),
  actionButton("connect_b", "Connect Service B"),
  selectInput("connection_id", "Saved authorization", choices = character()),
  actionButton("read", "Read records"),
  actionButton("disconnect", "Disconnect selected"),
  actionButton("logout", "Disconnect all and leave"),
  textOutput("result"),
  textOutput("auth_error")
)
ui <- oauth_connections_ui(base_ui, "services", manager)

server <- function(input, output, session) {
  auth <- oauth_connections_server("services", manager)
  observeEvent(input[["connect_a"]], auth[["connect"]]("a"))
  observeEvent(input[["connect_b"]], auth[["connect"]]("b"))

  observe({
    rows <- auth[["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 <- auth[["connections"]]()
    ids <- vapply(rows, function(x) x[["connection_id"]], character(1))
    req(input[["connection_id"]], input[["connection_id"]] %in% ids)
    auth[["connection"]](input[["connection_id"]])
  })

  result <- eventReactive(input[["read"]], {
    auth[["touch"]]()
    connection <- selected_connection()
    req(connection[["is_usable"]]())
    message <- tryCatch({
      response <- connection[["request"]](
        "api", "records", required_scopes = "records.read"
      )
      httr2::resp_check_status(response)
      # Parse the body here according to your API's schema.
      paste("Records request succeeded; HTTP", httr2::resp_status(response))
    }, error = function(e) "Could not read records. Check the connection and try again.")
    list(connection_id = connection[["id"]], message = message)
  })

  output[["result"]] <- renderText({
    connection <- selected_connection()
    req(connection[["is_usable"]]())
    value <- result()
    req(identical(value[["connection_id"]], connection[["id"]]))
    value[["message"]]
  })
  observeEvent(input[["disconnect"]], {
    auth[["disconnect"]](selected_connection()[["id"]])
  })
  observeEvent(input[["logout"]], auth[["logout"]]())
  output[["auth_error"]] <- renderText({
    if (length(auth[["errors"]]())) "Authorization is unavailable. Try connecting 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 A, return, then connect B. Both authorizations should appear in the selector. Select each and read its records. Disconnect removes the selected local connection; provider revocation may affect other authorizations as explained below. The numeric suffix only distinguishes rows in this demo; use the opaque connection_id as the stable key.

The UI wrapper handles callback pages; uiPattern = ".*" lets Shiny serve those paths. Use the same module ID ("services") in UI and server. Deploying under a subpath or behind a proxy can also require app_base_path and a trusted request_uri_resolver; see ?oauth_connections_ui.

Requests, permissions and refresh

For optional writes, first configure the applicable client with scopes = c("records.read", "records.write") and obtain a new authorization. Replace these example permissions with the provider’s actual scope names. Add actionButton("write", "Update selected record") to the UI and place this observer inside server(), after selected_connection is defined:

observeEvent(input[["write"]], {
  auth[["touch"]]()
  connection <- selected_connection()
  req(connection[["is_usable"]]())
  tryCatch({
    response <- connection[["request"]]("api", "records/example", method = "PUT",
      required_scopes = "records.write", configure = function(req) {
        req |>
          httr2::req_body_json(list(name = "Updated record")) |>
          httr2::req_headers(`If-Match` = 'W/"7"')
      })
    httr2::resp_check_status(response)
    showNotification("Record updated.")
  }, error = function(e) {
    showNotification("Could not update the record.", type = "error")
  })
})

configure adds a body or application headers to the unauthenticated request; httr2::req_body_form() and httr2::req_body_raw() are also supported. The connection retains control of its destination, authentication and redirect policy. The write scope is optional for connecting, but required for this action.

OAuthConnection[["request"]]() uses the existing perform_resource_req() transport. It adds selection of the current owned credentials and checks the approved API base and required permissions. Existing code using perform_resource_req(token, url) remains supported. For a single existing module, oauth_connection(client, reactive(auth[["token"]])) supplies the optional wrapper without adding retention.

resource_bases limits destinations, including absolute pagination links and their base paths. It does not automatically send OAuth’s resource parameter or prove the audience of an opaque token; configure resource indicators explicitly when your provider requires them. See RFC 8707.

required_scopes on the client is the minimum for usable access. Requesting additional optional scopes can produce a limited connection if only some are granted. Supply an operation’s additional permissions in [["request"]](); ordinary OAuth scope names are compared literally, and permissions are not inferred from the API path. A usable connection does not guarantee the API will accept every request.

If the provider issues a refresh token, the manager attempts refresh at expiry; refresh_proactively = TRUE on oauth_connections_server() starts earlier. Explicit refresh is also available inside a server observer:

observeEvent(input[["refresh"]], {
  tryCatch(selected_connection()[["refresh"]](), error = function(e) {
    showNotification("Could not refresh. Connect again if access has ended.", type = "error")
  })
})

Add actionButton("refresh", "Refresh selected") to the UI to use this snippet. The example uses the default synchronous mode. async = TRUE needs worker setup and promise handling for explicit refresh; see Usage.

Retention itself does not request a refresh token. OAuth refresh cannot expand the authorization’s scope (RFC 6749, section 6). [["refresh"]](scopes = ...) can deliberately narrow a managed connection’s accepted permissions; getting broader access again requires a new authorization.

Disconnect immediately removes local usability. Remote token revocation is best effort and reported separately in the return value. This template does not configure revocation endpoints, so add each provider’s documented revocation_url if supported. logout() also ends this local owner session; it does not sign the person out of the external services or your own account login system.

Separate connection IDs represent separate local records. A provider can reuse the same upstream grant for repeated authorizations and invalidate all related credentials when one is revoked; OAuth explicitly permits this behavior (RFC 7009, section 2.1). For example, Google’s documented revocation can affect all scopes granted to a project and invalidate related tokens (Google’s revocation policy). Repeated consent, distinct token strings, and local connection IDs do not prove independent upstream grants.

The default auth[["disconnect"]](id) requests remote revocation. To remove only the local connection, use auth[["disconnect"]](id, revoke = FALSE); those removed credentials remain valid remotely until expiry or provider-side revocation. Choose this deliberately using the provider’s documented behavior. Sibling connections can still report active locally after remote revocation: always handle API authorization failures and offer a new authorization.

Keep an ordinary OIDC login alongside SMART connections

For an OIDC client inside the manager, select validated identity explicitly in the owning server session:

identity <- selected_connection()[["identity"]](userinfo = c("name", "email"))
# identity[["id_token_claims"]] contains only iss and sub by default.
# identity[["userinfo"]] contains only the selected, previously fetched fields.

This requires a usable connection with openid and a validated ID token. UserInfo fields are returned only when its sub matches that token. Configure the provider’s UserInfo endpoint and required profile scopes if those fields are needed; the accessor does not fetch them. Treat the returned data as sensitive. Identity stays out of connection summaries and printing, and raw credentials are never returned. These snapshots can predate the latest OAuth refresh; they do not establish fresh login or an account-retention owner.

Use one callback wrapper with additional_clients for ordinary modules. Given your existing login_provider and a reviewed SMART discovery snapshot site, configure both registered callbacks with the same multi-server defense:

callbacks <- c("https://app.example/login/callback", "https://app.example/fhir/callback")
login_client <- oauth_client(login_provider, "registered-login-app",
  redirect_uri = callbacks[[1]], scopes = c("openid", "profile"),
  authorization_server_mode = "multi_redirect_uri",
  authorization_server_redirect_uris = callbacks)
fhir_client <- smart_client(site, "registered-fhir-app", callbacks[[2]],
  scopes = c("launch/patient", "patient/Patient.r"),
  authorization_server_mode = "multi_redirect_uri",
  authorization_server_redirect_uris = callbacks)
manager <- oauth_connections(list(fhir = fhir_client), "https://app.example",
  retention = "browser", owner_policy = oauth_browser_owner(),
  store = oauth_connection_store_memory(), keys = keys)
base_ui <- fluidPage(actionButton("sign_in", "Sign in"),
  actionButton("connect_fhir", "Connect FHIR"))
ui <- oauth_connections_ui(base_ui, "health", manager,
  additional_clients = list(login = login_client))
server <- function(input, output, session) {
  login <- oauth_module_server("login", login_client, auto_redirect = FALSE)
  health <- oauth_connections_server("health", manager)
  observeEvent(input[["sign_in"]], login[["request_login"]]())
  observeEvent(input[["connect_fhir"]], health[["connect"]]("fhir"))
}
shinyApp(ui, server, uiPattern = ".*")

Supply the existing registration’s secret or key to login_client as required. keys is the deployment key configuration described above. The additional client names are ordinary module IDs, without the manager namespace. Both GET and form-post callbacks are supported; select each registered response mode on its client. Additional clients need distinct routes from managed clients.

This preserves the ordinary module’s token and logout API. The manager neither stores that login token nor changes its lifecycle. A full browser navigation still ends the ordinary Shiny session; retain application login through your existing application-session mechanism if needed. Disable automatic login redirects while processing other authorizations, as in this example. Browser ownership for retained SMART grants is independent of OIDC identity; use account retention with a trusted local-account resolver when that binding is required.