External SDKs and database drivers

Obtain a connection from the module and ask it for a token immediately before calling a library that manages its own HTTP or database transport:

connection <- shiny::req(auth$connection())
token <- connection$access_token(required_scopes = "records.read")
existing_sdk_read_records(access_token = token)

The connection checks the owning Shiny session, authorization, scopes and remaining lifetime. If needed, it performs one coordinated refresh and checks the committed result again. It does not start login or retry the SDK operation. Configure the client for the API’s token audience and scopes; a valid token string alone does not prove access to a particular resource.

A single-authorization app

This complete app skeleton uses httr2 as the external library. Replace the example endpoints, scopes and registered callback URL with your provider’s values, and set OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET in the environment. The token and authenticated connection stay inside server().

library(shiny)
library(shinyOAuth)

provider <- oauth_provider(
  name = "records",
  auth_url = "https://login.example/authorize",
  token_url = "https://login.example/token",
  userinfo_required = FALSE
)
client <- oauth_client(
  provider,
  client_id = Sys.getenv("OAUTH_CLIENT_ID"),
  client_secret = Sys.getenv("OAUTH_CLIENT_SECRET"),
  redirect_uri = "https://app.example/callback",
  scopes = c("records.read", "records.write"),
  required_scopes = "records.read"
)

ui <- oauth_ui(fluidPage(
  actionButton("login", "Sign in"),
  actionButton("again", "Sign in again"),
  actionButton("logout", "Sign out"),
  actionButton("load", "Load records"),
  textOutput("permissions"),
  verbatimTextOutput("records")
), id = "auth", client = client)

server <- function(input, output, session) {
  auth <- oauth_module_server(
    "auth", client, auto_redirect = FALSE, refresh_proactively = TRUE
  )
  observeEvent(input$login, {
    auth$request_login()
    invisible(NULL)
  })
  observeEvent(input$again, {
    auth$reauthorize()
    invisible(NULL)
  })
  observeEvent(input$logout, auth$logout())

  output$permissions <- renderText({
    connection <- req(auth$connection())
    if (connection$has_scopes("records.write")) "Editing available" else "Read only"
  })

  records <- eventReactive(input$load, {
    connection <- req(auth$connection())
    data <- tryCatch({
      response <- httr2::request("https://api.example/records") |>
        httr2::req_auth_bearer_token(
          connection$access_token(required_scopes = "records.read")
        ) |>
        httr2::req_perform()
      httr2::resp_body_json(response)
    }, shinyOAuth_access_error = function(error) {
      showNotification("Authorization unavailable. Try again or sign in again.")
      req(FALSE)
    })
    list(authorization = connection$id, data = data)
  })

  output$records <- renderPrint({
    connection <- req(auth$connection())
    result <- records()
    req(identical(result$authorization, connection$id))
    req(connection$has_scopes("records.read"))
    result$data
  })
}

shinyApp(ui, server, uiPattern = ".*")

uiPattern = ".*" lets Shiny route the registered /callback path through oauth_ui() as well as serving the application root.

The output guard matters: eventReactive() retains its previous result until the next click. Checking the current authorization ID and permissions prevents that result from remaining visible after logout or a different login. Use the same guard for cached or background results. Clear application caches when no longer needed, and include the authorization ID in any session-local cache key. Never share user data through a global cache keyed only by a query string.

auth$connection() returns NULL before login and after the authorization is cleared. During one authorization it returns the same object and ID across refresh. Old references remain invalid after replacement, even if the same provider account signs in again. Token rotation alone does not rerun consumers of the factory, $access_token() or $has_scopes(); explicit status reads such as $summary() have finer reactive dependencies.

The single module’s existing expiry and session-age policies still apply. Proactive refresh helps keep a live authorization ready. With indefinite_session = TRUE, the module retains expired tokens, so the accessor can refresh them on demand; that option also disables automatic maximum-age clearing. Neither option makes an expired token usable without refresh.

Automatic authentication-age expiry ends the local authorization. A subsequent ordinary login requests the original configured scopes again. For example, after connection$refresh(scopes = "records.read") narrows a records.read records.write grant, ordinary login can request both permissions again. This also applies to token targets. Explicit auth$reauthorize() preserves the retained narrower limits. Configure only permissions the application should request at every fresh login; local narrowing does not change the client configuration or revoke the provider’s underlying consent.

Reauthorization retains requested offline_access consent separately from the access token’s reported scopes, since OIDC providers may omit that scope while issuing a refresh token. Explicit refresh scopes replace this consent policy too: include offline_access to retain previously authorized consent while narrowing API permissions, or omit it to keep it out of later reauthorization requests. Refresh cannot add consent that was absent or deliberately removed. Automatic refresh does not remove retained consent simply because a token omits it.

Standalone SMART replacements also retain requested launch/patient and launch/encounter context independently of access-token scopes, and obtain fresh context from the provider. Automatic refresh preserves these requests. Explicit refresh scopes replace the optional context policy too: omitting launch/encounter removes it from later replacement requests. Patient context is still requested whenever retained patient/ permissions require it.

Ordinary OAuth providers may report additional scopes from earlier consent. Reauthorization requests only retained configured permissions and seals any previously accepted extra scope evidence into the replacement transaction. Those same extras may remain in the returned token; they do not become available through the connection’s operation scope checks. A replacement or its automatic refresh cannot add new extras or restore configured permissions omitted from the retained limit. Explicit refresh narrowing still requires the returned token to fit the supplied scope request. Exported raw bearer tokens can carry the provider’s broader permissions; local checks do not reduce their authority at the API. Clients with no configured scopes continue to omit scope during replacement, even when the provider reports default permissions. Those permissions remain bounded response evidence. Ordinary OIDC replacement restores openid even when the preceding access token reported only provider-default permissions.

SDK callbacks and databases

If an SDK accepts a token callback, create its client once per authorization:

sdk <- reactive({
  connection <- req(auth$connection())
  example_sdk_client(
    token = function() connection$access_token(required_scopes = "records.read")
  )
})

Here example_sdk_client() represents your SDK’s constructor. The callback captures the authorization-bound connection, not a token snapshot or a lookup that silently follows future logins. Call it in the owning session’s reactive context. It cannot be serialized to a worker or invoked from another session.

For a driver that accepts only a string, retrieve it immediately before opening or updating the driver connection and follow that driver’s cleanup rules. A copied token does not refresh itself. Do not put tokens, SDK clients or database connections in global app state, logs, URLs or browser outputs. Token-only connections do not need resource_bases; shinyOAuth’s $request() still rejects every undeclared destination.

Run inserts and other writes from explicit input events. Check operation scopes at submission, acquire a current token, and handle the API’s own access checks. $has_scopes() only checks the recorded grant; it does not prove a database role, row permission or continued provider authorization. shinyOAuth never replays a business request after refresh or reauthorization.

Refresh, async work and recovery

$access_token(min_valid_for = 60) requires a known lifetime longer than the requested buffer, including your allowance for clock skew. force_refresh = TRUE bypasses the token cache but retains refresh coordination and retry pacing. A replacement that is still too short-lived fails without another refresh loop. Provider Retry-After delays are capped at one hour and apply to acquisition and explicit $refresh() calls, including targets or managed records sharing the refresh credential. A cached token that still meets the operation’s requirements remains usable during that delay. $refresh(scopes = ...) can narrow the grant while keeping required permissions; later refreshes retain that accepted limit. required_scopes on $access_token() only checks permissions and does not request different scopes.

# Configure mirai daemons or a non-sequential future plan before starting Shiny.
# Inside the owning Shiny session:
connection <- req(auth$connection())
pending <- connection$access_token(required_scopes = "records.read", async = TRUE)
promises::then(pending, function(token) {
  # Recheck before starting work after an asynchronous boundary.
  current <- req(auth$connection())
  req(identical(current$id, connection$id))
  req(current$has_scopes("records.read"))
  existing_sdk_read_records(access_token = token)
})

The async accessor always returns a promise, including cached results. A cached result is a snapshot: its callback can receive the old bearer string after logout, replacement, or scope narrowing. The ownership and permission checks above must run before passing it to the SDK. This only makes credential acquisition asynchronous; the SDK operation needs its own async transport if it would block Shiny. Concurrent async acquisitions join the same owned refresh. A synchronous call never waits on that work or returns a promise. Validate ownership again before displaying later SDK results. Scope narrowing keeps the connection ID, so also recheck the operation’s permissions before starting SDK work after an asynchronous boundary.

Authentication failures inherit shinyOAuth_access_error and expose error$context$reason:

Reason Meaning
authorization_unavailable The reference, authorization or owning session ended.
insufficient_scope The recorded grant does not cover the operation.
refresh_pending A synchronous caller cannot join an async refresh.
refresh_unavailable Refresh failed or retry pacing prevents another attempt.
interaction_required A new authorization is needed.
lifetime_unavailable The refreshed token still lacks the requested lifetime.
unsupported_token_binding A bearer string cannot supply the token’s binding.

Use the reason to present your own recovery message; argument and configuration errors remain separate. DPoP and certificate-bound tokens need their configured transport, such as $request(), instead of token export.

auth$reauthorize() invalidates the old local authorization immediately and starts replacement with its retained scope limit. It does not revoke the upstream grant, force a password/account chooser, or restore the old reference if the user cancels. Existing request_login() and logout() retain their behavior. No token accessor redirects the user automatically.

Several authorizations and optional UserInfo

With oauth_connections_server(), obtain the same connection interface through auth$connection(connection_id). auth$reauthorize(connection_id) replaces that authorization. Find the replacement in auth$connections() by its replaces_connection_id, then select its new ID. Unrelated grants remain usable; existing protections for shared refresh credentials still apply. Credential acquisition does not extend owner inactivity limits. Call auth$touch() from explicit user events where appropriate. See vignette("multiple-authorizations").

If selection is unnecessary, auth$connection() returns the sole retained authorization, or NULL when none remain. With several it raises shinyOAuth_access_error with reason selection_required. Expired and uncertain authorizations still count; the factory does not guess which account you intend.

For Microsoft API tokens that cannot call Graph UserInfo, use oauth_provider_microsoft(tenant = "organizations", userinfo_required = FALSE). This skips profile retrieval while preserving the preset’s OIDC validation. Request the appropriate OIDC scopes and use connection$identity() for selected validated ID-token claims. Generic oauth_provider() and OIDC configuration already support optional UserInfo. For one authorization with several API tokens, see vignette("token-targets"). With targets, identity() retains the last validated identity independently of access-token expiry or current openid scope; it does not prove fresh authentication or API access. Ordinary connections require a usable access token with openid currently granted. Query and database behavior belongs to the consuming library.

Background tasks and result ownership

Read Shiny reactives and acquire the credential before invoking an ExtendedTask (requires Shiny 1.8.1 or newer). Its function runs outside the reactive graph. Pass plain inputs and a short-lived bearer token to the worker, never the connection reference, module state or refresh credential. This follows Shiny’s ExtendedTask contract.

# Configure future::plan(future::multisession) before starting the app.
# Inside server(), with auth already created:
load_records <- shiny::ExtendedTask$new(function(token, authorization_id) {
  promises::future_promise({
    list(
      authorization_id = authorization_id,
      data = existing_sdk_read_records(access_token = token)
    )
  })
})

shiny::observeEvent(input$load, {
  shiny::req(load_records$status() != "running")
  connection <- shiny::req(auth$connection())
  token <- connection$access_token("records.read", min_valid_for = 120)
  load_records$invoke(token, connection$id)
})

output$records <- shiny::renderTable({
  connection <- shiny::req(auth$connection())
  shiny::req(connection$has_scopes("records.read"))
  result <- load_records$result()
  shiny::req(identical(result$authorization_id, connection$id))
  result$data
})

Choose a lifetime buffer that covers worker startup, any queue delay and the operation. Do not queue these token snapshots behind long tasks; reacquire near dispatch if your scheduler delays work. A copied bearer token cannot refresh itself. A background failure does not automatically resubmit the operation.

The reactive ownership and permission checks clear displayed data on logout, replacement, or removal of records.read, and reject a late result after any of those changes. Scope narrowing keeps the authorization ID, so check both. Keep these checks even when the task was started by an input event. Do not use cancelOutput = TRUE here, because it retains previously displayed data. For a manager with several authorizations, apply the same check to the explicitly selected connection ID.