--- title: "Keep and use multiple OAuth authorizations" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Keep and use multiple OAuth authorizations} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## Why keep several authorizations? A user may want to connect a calendar and a records service, or use two accounts at the same service. Each authorization gives the app permission to access data. The connection manager saves these authorizations so users can choose which one to use and disconnect them separately. Login redirects leave the current Shiny session. Browser retention lets the app find the saved authorizations when the user returns, so connecting a second service does not discard the first. For one login per Shiny session, start with [Usage](usage.html). For one login that needs different tokens for several APIs, see [Token targets](token-targets.html). ## Client configuration and connections A client describes an app registration: the service, credentials, requested permissions and allowed API addresses. A connection represents one successful authorization using that client. The manager holds the clients and saved connections; your server selects a connection when making a request. One client can have several connections. Connecting the same service again adds another authorization; the provider decides whether to show an account chooser or reuse the account already signed in there. ## Register two clients This template connects two fictional services and reads records from either one. Replace the endpoints, credentials, scope names and API paths with your providers' values. The example uses shared-secret client authentication and PKCE. Register these callback addresses, where the browser returns after login: | Service | Local callback URL | | --- | --- | | A | `http://127.0.0.1:8100/callback/a` | | B | `http://127.0.0.1:8100/callback/b` | Use these HTTP addresses only for local development if your providers permit them. For deployment, register matching public HTTPS URLs and remove `allow_http_loopback`. Store IDs and secrets in the environment variables below. Copy the next three code blocks into `app.R`, in order. ### 1. Configure clients outside `server()` ```{r multiple-clients, eval = FALSE} 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) ``` The names `a` and `b` let your app choose a service. Within either client, `api` names its approved API base: requesting `"api", "records"` on connection A calls `https://api.a.example/v1/records`. Each service has its own callback route so the app can distinguish returning responses. Give each client the complete `callbacks` vector. ### 2. Keep authorizations when the browser returns ```{r multiple-manager, eval = FALSE} # 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 ) ``` Tokens stay encrypted on the server, while a cookie associates the saved connections with this browser. The cookie does not identify a person. Without browser retention, connections end with their Shiny session. Create the manager, store and keys outside `server()`. This memory store supports one R process and loses records on restart. For deployment, load stable secret keys. Saved connections also have inactivity and absolute expiry limits. The Read handler below records user activity through `auth[["touch"]]()`. Automatic refreshes and status reads leave the inactivity timer unchanged. ### 3. Select a connection and make a request ```{r multiple-app, eval = FALSE} base_ui <- fluidPage( 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, then B. Select each authorization and read its records. The output checks the connection ID so results from a previous selection do not remain visible. The UI wrapper and server module share the `"services"` ID. The wrapper handles callbacks, and `uiPattern = ".*"` allows their routes. Selection uses the opaque connection ID; the numeric label only distinguishes demo rows. ## Requests, permissions and refresh For an optional write action, request `scopes = c("records.read", "records.write")` on the applicable client and obtain a new authorization. Add an Update button to the UI, then use an observer inside `server()` after `selected_connection`: ```{r multiple-request-body, eval = FALSE} 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") }) }) ``` The operation requires `records.write` even when basic read access is still usable. `configure` adds the request body and application headers; the connection keeps control of the destination and authentication. The service still decides whether the user may change that particular record. If a refresh token is available, the manager attempts refresh at expiry. `refresh_proactively = TRUE` starts earlier. An optional Refresh button can run: ```{r multiple-refresh, eval = FALSE} 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 use it. For background refresh, first follow [the async setup](usage.html#asynchronous-execution). Saving an authorization does not itself request a refresh token. ## Disconnecting a service `auth[["disconnect"]](id)` removes the selected local connection and attempts remote revocation when configured. `auth[["logout"]]()` disconnects all and ends this local owner session; it does not sign the user out of the external services. Add each provider's documented `revocation_url` if revocation is supported. A provider can reuse one upstream grant for several local connections, so revoking one may affect the others even if their local summaries still show them as active. `revoke = FALSE` removes local access without requesting remote revocation. ## Deployment notes Browser retention follows cookie boundaries, including sharing across ports on one hostname. The [browser-boundary explanation](advanced-security.html#browser-binding-and-deployment-boundary) describes this behavior. Browser retention is limited to one R process. [oauth_connections()](https://lukakoning.github.io/shinyOAuth/reference/oauth_connections.html) and [oauth_browser_owner()](https://lukakoning.github.io/shinyOAuth/reference/oauth_browser_owner.html) document retention, capacity and expiry settings. If your app has its own verified account login, `retention = "account"` with `oauth_account_owner()` can associate authorizations with that account. Browser retention alone does not make that association. For subpaths and trusted proxies, see [oauth_connections_ui()](https://lukakoning.github.io/shinyOAuth/reference/oauth_connections_ui.html). ## Keep an ordinary OIDC login alongside SMART connections An ordinary OIDC login can identify the app user while the manager holds separate authorizations for access to hospital records. For an OIDC client already inside the manager, read selected validated identity fields in the owning server session: ```{r multiple-identity, eval = FALSE} 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 reads previously fetched identity data; it does not perform a fresh login or establish the account used for retention. See [the connection reference](https://lukakoning.github.io/shinyOAuth/reference/OAuthConnection.html). To keep an ordinary login module outside the manager, use one callback wrapper with `additional_clients`. Given an existing `login_provider` and a reviewed SMART discovery result `site`: ```{r mixed-login-configuration, eval = FALSE} 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 credentials for the login registration and the deployment `keys` described above. The example uses distinct callbacks and disables automatic login so the ordinary login module does not interrupt other authorizations. Full navigation ends the ordinary module's Shiny session. Its login is separate from the manager's retained connections. See [oauth_connections_ui()](https://lukakoning.github.io/shinyOAuth/reference/oauth_connections_ui.html) for callback configuration details.