Keep and use multiple OAuth authorizations
Source:vignettes/multiple-authorizations.Rmd
multiple-authorizations.RmdshinyOAuth 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.