OAuth 2.0 authorization and OIDC authentication module for Shiny
Source:R/oauth_module_server.R
oauth_module_server.RdManage login for a Shiny server module. The module sends the browser to the
provider, validates the return, and publishes reactive login status and user
information. An oauth_client() supplies the registration and policy;
oauth_ui() handles query callbacks and oauth_form_post_ui() handles POST
callbacks. The matching UI wrapper includes browser setup.
With an OpenID Connect (OIDC) provider, it also validates the signed-in user's identity.
Usage
oauth_module_server(
id,
client,
auto_redirect = TRUE,
async = FALSE,
indefinite_session = FALSE,
reauth_after_seconds = NULL,
refresh_proactively = FALSE,
refresh_lead_seconds = 60,
refresh_check_interval_ms = 10000,
revoke_on_session_end = FALSE,
tab_title_cleaning = TRUE,
tab_title_replacement = NULL,
request_uri_base_url = NULL,
browser_cookie_path = NULL,
browser_cookie_samesite = c("Strict", "Lax", "None"),
refresh_check_interval = NULL
)Arguments
- id
A name for this Shiny module, such as
"auth".- client
The app configuration created with
oauth_client().- auto_redirect
If
TRUE(default), start login automatically for unauthenticated sessions. IfFALSE, callauth[["request_login"]]()to start it.- async
If
TRUE, run the module's network work through a background backend. Configure mirai daemons or a non-sequential future plan first; mirai takes priority when both are configured. DefaultFALSE.future::sequential()runs in the main process. See Asynchronous execution for operations that remain synchronous.- indefinite_session
If TRUE, the module will not automatically clear the token due to access-token expiry or the
reauth_after_secondswindow, and it will not trigger automatic reauthentication when a token expires or a refresh fails.- reauth_after_seconds
Maximum local login age in seconds, or
NULL(default) for no forced reauthentication. Refresh does not reset this timer;indefinite_session = TRUEignores it. When the limit is reached, local access is cleared and a new login is required. With token targets, API-token expiry alone need not end local login; this argument limits the whole authorization. SMART clients using OIDC requireNULLunlessindefinite_session = TRUE. See Session lifetime for provider behavior.- refresh_proactively
If
TRUE, obtain a replacement access token before expiry when a refresh token is available. DefaultFALSE. The module schedules refresh at approximatelyexpires_at - refresh_lead_seconds.- refresh_lead_seconds
Number of seconds before expiry to attempt proactive refresh (default: 60). An already running refresh, including one requested on demand, has this many seconds plus 5 after access-token expiry to finish before the module clears the authorization. This grace period never extends
reauth_after_seconds.- refresh_check_interval_ms
Fallback interval in milliseconds for checking expiry and refresh (default 10000). Known expiry times are scheduled directly; this interval is used as a safety check or when expiry is unknown or infinite.
- revoke_on_session_end
If
TRUE, attempt to revoke provider tokens when the Shiny session ends or the module clears the login after expiry, the maximum login age or a refresh failure. A retained login keeps its grant. DefaultFALSE. Requires a providerrevocation_url. Revocation is best effort and runs in the background only withasync = TRUE; local cleanup still happens if the provider is unavailable.- tab_title_cleaning
If TRUE (default), removes any query string suffix from the browser tab title after the OAuth callback, so titles like "localhost:8100?code=...&state=..." become "localhost:8100"
- tab_title_replacement
Optional character string to explicitly set the browser tab title after the OAuth callback. If provided, it takes precedence over
tab_title_cleaning- request_uri_base_url
Optional absolute base URL used when
request_object_mode = "request_uri"publishes Request Objects through Shiny. By default (NULL), shinyOAuth derives the base URL from the current browser-visible app origin, but only whenoptions(shinyOAuth.allowed_hosts = ...)pins the permitted public host. Set this when the authorization server must fetch the published Request Object through a different public host or proxy address than the browser uses, or when you prefer to declare the public origin explicitly. The value must use HTTPS and contain no query string or fragment. Caller-published Request Object URLs require HTTPS even when the ordinaryis_ok_host()policy permits HTTP for that host (RFC 9101 Section 5.2). Wrap the app inoauth_ui(ui, id, client)oroauth_form_post_ui()to serve these URLs. Handles contain no Shiny session token and expire within 120 seconds. Shared workers require a shared state store with atomictake().URL path covered by the login cookie. Default
NULLuses"/", covering all app routes. An explicit path, such as"/app", must cover both the starting page and callback, start with/, and contain no semicolons or control characters. On HTTPS the path is always"/"and the cookie always uses the__Host-prefix to prevent sibling-domain cookie injection. Module identifiers isolate cookie names. Custom paths apply only to HTTP development; HTTP cannot provide this protection.Cookie setting controlling when the browser sends the login cookie on requests from other sites. One of
"Strict"(default),"Lax", or"None"."Lax"allows the cookie on top-level cross-site navigations, which some proxy arrangements require."None"also allows cross-site cookie use in other contexts; it requires HTTPS and sets the cookie'sSecureattribute.- refresh_check_interval
Compatibility alias for
refresh_check_interval_ms. Supply only one spelling.
Value
A shiny::reactiveValues() object. If you assign it to auth,
its main fields are:
auth[["authenticated"]]:TRUEwhen a token is present and the configured checks have passed, otherwiseFALSE. Withindefinite_session = TRUE, the flag stays true while a token is kept, including after refresh errors. With token targets, it tracks the retained authorization across individual token expiry and recoverable target failures. Connection methods check the selected token's lifetime and permissions before use; the configured authentication-age limit still applies to the whole authorization.auth[["token"]]: an OAuthToken, orNULLbefore login or after clearing the session. Read properties with@, for exampleauth[["token"]]@userinfo. Additional token response parameters are available inauth[["token"]]@extra_fields;auth[["token"]]@initial_extra_fieldspreserves the parameters from the initial code exchange across refreshes.auth[["error"]],auth[["error_description"]]: the error code and available diagnostic detail. Use your own user-facing message; these fields can include sensitive provider information.auth[["error_uri"]]: an optional provider help URL. Only absolute HTTPS URLs on provider or explicitly allowed hosts are surfaced. Treat it as untrusted navigation input.NULLmeans the provider omitted the URL or supplied a value that did not pass validation.auth[["token_stale"]]:TRUEwhen a target authorization retains an expired primary token, or an indefinite session keeps an expired token or one whose refresh failed. Resets after successful login, primary-token refresh, or authorization clearing, including logout and maximum authentication age. Refreshing a secondary target does not renew the primary token.
The object also supplies:
auth$connection(): the current OAuthConnection, orNULLbefore login or after the module clears the authorization. The reference and its$idsurvive refresh; logout, reauthorization and session closure invalidate it. Useconnection <- shiny::req(auth$connection())inside reactive code, thenconnection$access_token()for a server-side SDK or database driver. Accessor-triggered refresh works withoutrefresh_proactively = TRUE.auth$reauthorize(): invalidate the current local authorization and begin replacement with its retained scope limit, without upstream revocation. Local state preparation is checked first; rejection preserves current access. Failed/cancelled replacement after that does not revive old references. With no prior grant, this starts ordinary login. It does not force account selection or a password prompt; configured maximum authentication age still applies.auth[["request_login"]](): start login. Waits for browser setup when needed and does nothing if the session is already authenticated. Uses a browser form when the client selectsauthorization_method = "POST"; the app's Content Security Policyform-actionmust permit the provider endpoint.auth[["logout"]](): clear the local login and attempt to revoke tokens when supported, followingasync. It does not sign out of the provider account.auth[["build_auth_url"]](): advanced helper for a custom login link. Rejects POST clients; userequest_login()for their form submission. Creates pending login state as well as the URL, so retain the result for the link instead of rebuilding it on every UI update. Rotates and checks the browser binding before creating state. Returns a promise resolving to the URL (orNAon failure or an obsolete result); usepromises::then(). PAR URLs carryshinyOAuth.par_request_uri,shinyOAuth.par_expires_in, andshinyOAuth.par_expires_atattributes to help you decide when to regenerate the link.request_login()handles these details for button-based login. A custom observer's promise handler runs after browser acknowledgment. Returninginvisible(NULL)from that observer lets Shiny process the acknowledgment; awaiting the pending promise in the observer blocks it.auth[["has_browser_token"]](): reports browser-binding readiness, independently of authentication state. The normal login methods manage browser setup automatically.auth[["set_browser_token"]](): asks the browser to establish its binding when missing. The token becomes available after the browser reports it back to Shiny.auth[["clear_browser_token"]](): clears the browser binding.request_login()manages cookie setup automatically, andlogout()handles cookie rotation when ending a session.
Other fields manage the module internally and are not needed in app code.
Details
Login starts automatically by default. With auto_redirect = FALSE,
auth[["request_login"]]() starts the same flow explicitly. The module publishes
auth[["authenticated"]] and the accepted token after its configured checks pass.
After login, auth$connection() returns a session-bound reference to the
current credentials. connection$request() calls a configured API, while
connection$access_token() supplies a token for another R package. See
vignette("external-integrations", package = "shinyOAuth")
(online guide)
for examples.
See the usage vignette for a complete app and instructions for registration, API calls, and deployment.
Asynchronous execution
With async = TRUE and configured mirai workers or a non-sequential future
plan, provider requests run outside the main R process. Synchronous network
waits otherwise delay Shiny sessions sharing that process.
Browser setup
The UI wrapper handles the registered callback URL. IDE viewers and embedded dashboard previews can block the browser navigation used during login. On Posit Connect and Connect Cloud, the direct app URL provides top-level navigation; Connect Cloud exposes it under Settings > URL.
The module establishes browser binding through cookies, tab-scoped session storage and Web Crypto, and checks it on return. The advanced security vignette describes these browser boundaries and their deployment implications.
Session lifetime
Token refresh does not extend the configured maximum login age. A new login
after that age expires requests the client's configured permissions again;
explicit auth$reauthorize() keeps any previously reduced permissions.
For OIDC, age-based reauthentication requests max_age = 0 and requires a
valid auth_time in the returned identity token. OAuth-only providers cannot
guarantee an active provider login, so the age limit ends local access and
starts ordinary authorization. With token targets, authenticated describes
the retained login, while connection methods check the selected API token.
Advanced worker behavior
Signed-request preparation, pushed authorization requests (PAR) and query
JARM verification can run in workers. State-store access and publication of
hosted Request Objects stay in the main process. Setup-time discovery,
standalone prepare_call() and JARM verification in oauth_form_post_ui()
remain synchronous. Configure timeouts for those operations; see the
package options reference.
Examples
# Register http://127.0.0.1:8100 as the GitHub OAuth App callback URL.
if (
# Example requires configured GitHub OAuth 2.0 app
# (go to https://github.com/settings/developers to create one):
nzchar(Sys.getenv("GITHUB_OAUTH_CLIENT_ID")) &&
nzchar(Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET")) &&
interactive()
) {
library(shiny)
library(shinyOAuth)
# Define client
client <- oauth_client(
provider = oauth_provider_github(),
client_id = Sys.getenv("GITHUB_OAUTH_CLIENT_ID"),
client_secret = Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET"),
redirect_uri = "http://127.0.0.1:8100",
scopes = c("read:user", "user:email")
)
# Choose which app you want to run
app_to_run <- NULL
while (!isTRUE(app_to_run %in% c(1:4))) {
app_to_run <- readline(
prompt = paste0(
"Which example app do you want to run?\n",
" 1: Auto-redirect login\n",
" 2: Manual login button\n",
" 3: Fetch additional resource with access token\n",
" 4: No app (all will be defined but none run)\n",
"Enter 1, 2, 3, or 4... "
)
)
}
if (app_to_run %in% c(1:3)) {
cli::cli_alert_info(paste0(
"Will run example app {app_to_run} on {.url http://127.0.0.1:8100}\n",
"Open this URL in a regular browser (viewers in RStudio/Positron/etc. ",
"cannot perform necessary redirects)"
))
}
# Example app with auto-redirect (1) -----------------------------------------
ui_1 <- oauth_ui(
fluidPage(
uiOutput("login")
),
id = "auth",
client = client
)
server_1 <- function(input, output, session) {
# Auto-redirect (default):
auth <- oauth_module_server(
"auth",
client,
auto_redirect = TRUE
)
output[["login"]] <- renderUI({
if (auth[["authenticated"]]) {
user_info <- auth[["token"]]@userinfo
tagList(
tags[["p"]]("You are logged in!"),
tags[["pre"]](paste(capture.output(str(user_info)), collapse = "\n"))
)
} else {
tags[["p"]]("You are not logged in.")
}
})
}
app_1 <- shinyApp(ui_1, server_1)
if (app_to_run == "1") {
runApp(
app_1,
port = 8100,
launch.browser = FALSE
)
}
# Example app with manual login button (2) -----------------------------------
ui_2 <- oauth_ui(
fluidPage(
actionButton("login_btn", "Login"),
actionButton("logout_btn", "Logout"),
uiOutput("login")
),
id = "auth",
client = client
)
server_2 <- function(input, output, session) {
auth <- oauth_module_server(
"auth",
client,
auto_redirect = FALSE
)
observeEvent(input[["login_btn"]], {
auth[["request_login"]]()
})
observeEvent(input[["logout_btn"]], {
auth[["logout"]]()
})
output[["login"]] <- renderUI({
if (auth[["authenticated"]]) {
user_info <- auth[["token"]]@userinfo
tagList(
tags[["p"]]("You are logged in!"),
tags[["pre"]](paste(capture.output(str(user_info)), collapse = "\n"))
)
} else {
tags[["p"]]("You are not logged in.")
}
})
}
app_2 <- shinyApp(ui_2, server_2)
if (app_to_run == "2") {
runApp(
app_2,
port = 8100,
launch.browser = FALSE
)
}
# Example app requesting additional resource with access token (3) -----------
# Below app shows the authenticated username + their GitHub repositories,
# fetched via GitHub API using the access token obtained during login
ui_3 <- oauth_ui(
fluidPage(
uiOutput("ui")
),
id = "auth",
client = client
)
server_3 <- function(input, output, session) {
auth <- oauth_module_server(
"auth",
client,
auto_redirect = TRUE
)
repositories <- reactiveVal(NULL)
repository_error <- reactiveVal(FALSE)
observe({
req(auth[["authenticated"]])
# Example additional API request using the access token
# (e.g., fetch user repositories from GitHub)
# This loads one page; use the API's pagination for further results.
repos_data <- tryCatch(
{
resp <- perform_resource_req(
auth[["token"]],
"https://api.github.com/user/repos",
query = list(per_page = 30)
)
httr2::resp_check_status(resp)
httr2::resp_body_json(resp, simplifyVector = TRUE)
},
error = function(e) NULL
)
repository_error(is.null(repos_data))
repositories(repos_data)
})
# Render username + their repositories
output[["ui"]] <- renderUI({
if (isTRUE(auth[["authenticated"]])) {
user_info <- auth[["token"]]@userinfo
repos <- repositories()
return(tagList(
tags[["p"]](paste("You are logged in as:", user_info[["login"]])),
tags[["h4"]]("Your repositories:"),
if (repository_error()) {
tags[["p"]]("Could not load repositories.")
} else if (!is.null(repos) && length(repos) == 0) {
tags[["p"]]("No repositories returned.")
} else if (!is.null(repos)) {
tags[["ul"]](
Map(
function(url, name) {
# Render names as text; accept only GitHub HTTPS links.
if (isTRUE(grepl("^https://github\\.com/", url))) {
tags[["li"]](tags[["a"]](
href = url,
target = "_blank",
rel = "noopener noreferrer",
name
))
} else {
tags[["li"]](name)
}
},
repos[["html_url"]],
repos[["full_name"]]
)
)
} else {
tags[["p"]]("Loading repositories...")
}
))
}
return(tags[["p"]]("You are not logged in."))
})
}
app_3 <- shinyApp(ui_3, server_3)
if (app_to_run == "3") {
runApp(
app_3,
port = 8100,
launch.browser = FALSE
)
}
}