OAuth 2.0 & OIDC authentication module for Shiny applications
Source:R/oauth_module_server.R
oauth_module_server.RdThis function implements a Shiny module server that manages OAuth 2.0/OIDC authentication for Shiny applications. It handles the OAuth 2.0/OIDC flow, including redirecting users to the authorization endpoint, securely processing the callback, exchanging authorization codes for tokens, verifying tokens, and managing token refresh. It also provides options for automatic or manual login flows, session expiry, and proactive token refresh.
Note: when using this module, you must include
shinyOAuth::use_shinyOAuth() in your UI definition to load the
necessary JavaScript dependencies.
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 = 10000,
revoke_on_session_end = FALSE,
tab_title_cleaning = TRUE,
tab_title_replacement = NULL,
browser_cookie_path = NULL,
browser_cookie_samesite = c("Strict", "Lax", "None")
)Arguments
- id
Shiny module id
- client
OAuthClient object
- auto_redirect
If TRUE (default), unauthenticated sessions will immediately initiate the OAuth flow by redirecting the browser to the authorization endpoint. If FALSE, the module will not auto-redirect; instead, the returned object exposes helpers for triggering login manually (use
$request_login()).- async
If TRUE, dispatches token exchange and refresh through shinyOAuth's async promise path and updates values when the promise resolves. mirai::mirai is preferred when daemons are configured with
mirai::daemons(). Otherwise, if promises::promises and future::future are installed, the current future::future plan is used. Non-sequential future plans run off the main R session;future::sequential()stays in-process. If FALSE (default), token exchange and refresh are performed synchronously (which may block the Shiny event loop). For production apps,async = TRUEis usually the better choice.- 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 session age in seconds. If set, the module will remove the token (and thus set
authenticatedto FALSE) after this many seconds have elapsed since authentication started. By default this isNULL(no forced re-authentication). If a value is provided, the timer is reset after each successful refresh so the knob is opt-in and counts rolling session age.- refresh_proactively
If TRUE, will automatically refresh tokens before they expire (if refresh token is available). The refresh is scheduled adaptively so that it executes approximately at
expires_at - refresh_lead_secondsrather than on a coarse polling loop.- refresh_lead_seconds
Number of seconds before expiry to attempt proactive refresh (default: 60)
- refresh_check_interval
Fallback check interval in milliseconds for expiry/refresh (default: 10000 ms). When expiry is known, the module uses adaptive scheduling to wake up exactly when needed; this interval is used as a safety net or when expiry is unknown/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_cleaningOptional cookie Path to scope the browser token cookie. By default (
NULL), the path is fixed to "/" for reliable clearing across route changes. Provide an explicit path (e.g., "/app") to narrow the cookie's scope to a sub-route. Explicit values must start with/and must not contain semicolons or control characters. Note: when the path is "/" and the page is served over HTTPS, the cookie name uses the__Host-prefix (Secure, Path=/) for additional hardening; when the path is not "/", a regular cookie name is used.For apps deployed under nested routes or where the OAuth callback may land on a different route than the initial page, keeping the default (root path) ensures the browser token cookie is available and clearable across app routes. If you deliberately scope the cookie to a sub-path, make sure all relevant routes share that prefix.
SameSite value for the browser-token cookie. One of "Strict", "Lax", or "None". Defaults to "Strict" for maximum protection against cross-site request forgery. Use "Lax" only when your deployment requires the cookie to accompany top-level cross-site navigations (for example, because of reverse-proxy flows), and document the associated risk. If set to "None", the cookie will be marked
SameSite=None; Securein the browser, and authentication will error on non-HTTPS origins because browsers rejectSameSite=Nonecookies without theSecureattribute
Value
A reactiveValues object with token, error, error_description,
error_uri, and authenticated, plus additional fields used by the module.
The returned reactiveValues contains the following fields:
authenticated: logical TRUE when there is no error and a token is present and valid (matching the verifications enabled in the client provider); FALSE otherwise. Exception: whenindefinite_session = TRUE, errors do not affect this flag soauthenticatedremains TRUE even if refresh or other operations fail.token: OAuthToken object, or NULL if not yet authenticated. This contains the access token, refresh token (if any), ID token (if any), userinfo (if fetched), and the decoded ID token claims viatoken@id_token_claims(a read-only named list exposing all JWT payload claims such assub,acr,amr,auth_time, etc.). See OAuthToken for details. Because OAuthToken is a S7 object, you access its fields with@, e.g.,token@userinfoortoken@id_token_claims$acr.error: error code string when the OAuth flow fails. Be careful about showing this directly to users, because it may contain sensitive information.error_description: human-readable error detail when available. Be extra careful about showing this directly to users, because it may contain even more sensitive information.error_uri: URI identifying a human-readable web page with information about the error (per RFC 6749 section 4.1.2.1). Treat this as untrusted navigation input; shinyOAuth only surfaces absolute HTTPS values here and returns NULL when the provider omits or sends an unsafe value.browser_token: internal opaque browser cookie value; used for state double-submit protection; NULL if not yet setpending_callback: internal deferred callback payload; stores either list(type = "code", code, state, iss) for authorization-code callbacks or list(type = "error", error, error_description, error_uri, state, iss) for provider error callbacks. Used to defer callback handling untilbrowser_tokenis available; NULL otherwise.pending_login: internal logical; TRUE when a login was requested but must wait forbrowser_tokento be set, FALSE otherwise.auto_redirected: internal logical; TRUE once the module has initiated an automatic redirect in this session to avoid duplicate redirects.reauth_triggered: internal logical; TRUE once a reauthentication attempt has been initiated (after expiry or failed refresh), to avoid loops.auth_started_at: internal numeric timestamp (as fromSys.time()) when authentication started; NA if not yet authenticated. Used to enforcereauth_after_secondsif set.token_stale: logical; TRUE when the token was kept despite a refresh failure becauseindefinite_session = TRUE, or when the access token is past its expiry butindefinite_session = TRUEprevents automatic clearing. This lets UIs warn users or disable actions that require a fresh token. It resets to FALSE on successful login, refresh, or logout.last_login_async_used: internal logical; TRUE if the last login attempt usedasync = TRUE, FALSE if it was synchronous. This is only used for testing and diagnostics.refresh_in_progress: internal logical; TRUE while a token refresh is currently in flight (async or sync). Used to prevent concurrent refresh attempts when proactive refresh logic wakes up multiple times.
It also contains the following helper functions, mainly useful when
auto_redirect = FALSE and you want to start login from your own UI
(for example, from a button):
request_login(): initiates login by redirecting to the authorization endpoint, with cookie-ensure semantics: ifbrowser_tokenis missing, the module sets the cookie and defers the redirect untilbrowser_tokenis present, then redirects. If the module is already authenticated, the request is ignored and no new OAuth state is created. This is the main entry point for login whenauto_redirect = FALSE.logout(): if a token is present, makes best-effort revocation requests for the refresh token and access token when the provider exposes a revocation endpoint. This may perform network I/O, can revoke refresh tokens, and follows the module'sasyncsetting. It then clears the current token, setsauthenticatedto FALSE, and rotates the browser token cookie. You might call this when the user clicks a logout button.build_auth_url(): internal; builds and returns the authorization URL, also storing the relevant state in the client'sstate_store(for validation during callback). Note that this requiresbrowser_tokento be present, so it will throw an error if called too early. When the module is already authenticated it returnsNAand does not mint new state (verify withhas_browser_token()first). When PAR is used, the returned string keepsshinyOAuth.par_request_uri,shinyOAuth.par_expires_in, andshinyOAuth.par_expires_atattributes so manual link-style flows can decide when to regenerate it. Typically you would not call this directly, but userequest_login()instead, which calls it internally.set_browser_token(): internal; injects JS to set the browser token cookie if missing. Normally called automatically on first load, but you can call it manually if needed. If a token is already present, it will return immediately without changing it (callclear_browser_token()if you want to force a reset). Typically you would not call this directly, but userequest_login()instead, which calls it internally if needed.clear_browser_token(): internal; injects JS to clear the browser token cookie and clearsbrowser_token. You might call this to reset the cookie if you suspect it's stale or compromised. Typically you would not call this directly.has_browser_token(): internal; returns TRUE ifbrowser_tokenis present (non-NULL, non-empty), FALSE otherwise. Typically you would not call this directly
Details
Most apps only need to decide whether login starts automatically, whether to enable async mode, and whether token refresh should happen proactively. The remaining arguments are mainly for deployments that need tighter control over session lifetime, logout behavior, or browser cookie settings.
Blocking vs. async behavior: when
async = FALSE(the default), network operations like token exchange and refresh are performed on the main R thread. Transient errors are retried by the package's internalreq_with_retry()helper, which currently usesSys.sleep()for backoff. In Shiny,Sys.sleep()blocks the event loop for the entire worker process, potentially freezing UI updates for all sessions on that worker during slow provider responses or retry backoff. To keep the UI responsive: setasync = TRUEand configure an async backend that runs off the main process, such as mirai::mirai daemons (mirai::daemons(n)) or a non-sequential future::future plan, or reduce/block retries (seevignette("usage", package = "shinyOAuth")).Browser requirements: the module relies on the browser's Web Crypto API to generate a secure, per-session browser token used for state double-submit protection. Specifically, the login flow requires
window.crypto.getRandomValuesto be available. If it is not present (for example, in some very old or highly locked-down browsers), the module will be unable to proceed with authentication. In that case a client-side error is emitted and surfaced to the server asshinyOAuth_cookie_errorcontaining the message"webcrypto_unavailable". Use a modern browser (or enable Web Crypto) to resolve this.Browser cookie lifetime: the opaque browser token cookie lifetime mirrors the client's
state_storeTTL. Internally, the module readsclient@state_store$info()$max_ageand uses that value for the cookie'sMax-Age/Expires. When the cache does not expose a finitemax_age, a conservative default of 5 minutes (300 seconds) is used to align with the built-incachem::cache_mem(max_age = 300)default. Separately, the state payloadissued_atfreshness window is controlled by the client'sstate_payload_max_age(default 300 seconds).
Examples
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"
)
# 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 <- fluidPage(
use_shinyOAuth(),
uiOutput("login")
)
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 <- fluidPage(
use_shinyOAuth(),
actionButton("login_btn", "Login"),
uiOutput("login")
)
server_2 <- function(input, output, session) {
auth <- oauth_module_server(
"auth",
client,
auto_redirect = FALSE
)
observeEvent(input$login_btn, {
auth$request_login()
})
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 <- fluidPage(
use_shinyOAuth(),
uiOutput("ui")
)
server_3 <- function(input, output, session) {
auth <- oauth_module_server(
"auth",
client,
auto_redirect = TRUE
)
repositories <- reactiveVal(NULL)
observe({
req(auth$authenticated)
# Example additional API request using the access token
# (e.g., fetch user repositories from GitHub)
req <- client_bearer_req(auth$token, "https://api.github.com/user/repos")
resp <- httr2::req_perform(req)
if (httr2::resp_is_error(resp)) {
repositories(NULL)
} else {
repos_data <- httr2::resp_body_json(resp, simplifyVector = TRUE)
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 (!is.null(repos)) {
tags$ul(
Map(
function(url, name) {
tags$li(tags$a(href = url, target = "_blank", 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
)
}
}