OAuth 2.0 authorization and OIDC authentication module for Shiny
Source:R/oauth_module_server.R
oauth_module_server.RdCall oauth_module_server() inside your Shiny server() function to manage
login for each user. It sends users to the provider, checks their return,
and gives your app reactive login status and user information.
Create client with oauth_client() outside server(), and wrap your
complete UI with oauth_ui(ui, id = "auth", client = client), using the
same module ID and client. Use oauth_form_post_ui() for POST callbacks.
These wrappers include the browser dependency; no separate
use_shinyOAuth() call is needed.
This uses the OAuth 2.0 Authorization Code flow, with OpenID Connect (OIDC) identity checks when configured for an OIDC provider.
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. This effectively makes sessions "indefinite" from the module's perspective once a user has logged in. Note that your API calls may still fail once the provider considers the token expired; this option only affects the module's automatic clearing and redirect behavior.- reauth_after_seconds
Optional maximum interactive-authentication age in seconds. If set, the module removes the token (and thus sets
authenticatedto FALSE) after this many seconds. Token refresh does not reset the timer. For OIDC providers, reauthentication requests sendmax_age=0; the returned ID token must contain a validauth_time, which is used as the next authentication start. OAuth-only providers have no standard way to require active user authentication, so for them this is a hard local session lifetime followed by an ordinary authorization request. By default this isNULL(no forced reauthentication).- 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)
- 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, automatically revokes provider tokens when the Shiny session ends (e.g., browser tab closed, session timeout). This is a best-effort operation. Revocation runs asynchronously only when the module is configured with
async = TRUE(otherwise it runs synchronously). Requires the provider to have arevocation_urlconfigured. Default is FALSE. Note that session-end revocation may not always succeed (e.g., network issues, provider unavailable), so combine with appropriate token lifetimes on the provider side.- 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. Keep"Strict"unless the deployment needs these broader cookie-sending rules.- 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.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 an indefinite session keeps an expired token or one whose refresh failed. Resets after successful login, refresh, or logout.
The object also supplies:
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. InsideobserveEvent(), register the promise handler and then returninvisible(NULL)so Shiny can process the browser acknowledgment. Do not return the pending promise from the observer itself.auth[["has_browser_token"]](): reports whether the browser token is available. Use it before building a custom login URL; it does not report whether the user is authenticated.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. An existing token is left unchanged.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. Use auto_redirect = FALSE and
auth[["request_login"]]() to start it from a button. Read auth[["authenticated"]]
in reactive code, and use req(auth[["authenticated"]]) before server operations
that require login. Your app must also enforce its own access rules.
See the usage vignette for a complete app and instructions for registration, API calls, and deployment.
Asynchronous execution
With async = TRUE, configure mirai::daemons() or a non-sequential
future::plan() before starting the app. Slow provider requests can then
run outside the main R process. Without this, network waits can delay all
Shiny sessions sharing that process.
Advanced operations sent to workers include PAR, signed Request Object
preparation, and query JARM verification. State-store operations and Shiny
Request Object publication stay in the main process. Discovery during app
setup, standalone prepare_call(), and JARM verification in
oauth_form_post_ui() remain synchronous. Use timeouts on those network
and storage operations; see the package options reference.
Browser setup
Open the app at its registered return address in a regular browser with
cookies, session storage, and Web Crypto enabled. Embedded IDE viewers may
prevent login. On Posit Connect or Connect Cloud, open the direct app URL in
a new browser tab rather than using the dashboard's embedded preview. In
Connect Cloud, copy the sharing URL from Settings > URL. Register the
exact public callback URL with your provider, including any callback path.
The module emits a reminder once per R process when Posit's deployment
environment markers are present; it does not detect whether a page is embedded.
The binding token stays in origin- and tab-scoped session storage; the
cookie contains an independent marker, which must match the stored record.
The temporary browser cookie follows the state store's max_age, with a
300-second fallback when that lifetime is unavailable. The separate
state_payload_max_age client setting limits the age of the login request.
Each new login uses a fresh server-selected browser binding and its own marker
cookie. Application callback routes and module namespaces identify the storage
record. Separate tabs can complete logins independently; complete a login in
the tab that started it. Starting another login in the same tab and module
replaces that tab's pending binding. Pending logins must be restarted after
upgrading from versions that used local storage.
Private browser-binding inputs are excluded from Shiny bookmarks. Do not
copy auth[["browser_token"]] into custom bookmark values, URLs, or logs.
Treat the entire hostname as a trust boundary: cookies are shared across
ports, even with __Host-, Secure, or HttpOnly. Use a dedicated hostname
when other services are not trusted. The origin-scoped check prevents cookie
adoption across ports, but co-hosted services can still disrupt cookies.
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
)
}
}