diff --git a/DESCRIPTION b/DESCRIPTION index 314eb71f..4b3e48f3 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -30,9 +30,11 @@ Imports: Suggests: askpass, bench, + bslib, clipr, covr, docopt, + htmltools, httpuv, jose, jsonlite, @@ -40,6 +42,8 @@ Suggests: later, promises, rmarkdown, + shiny, + sodium, testthat (>= 3.1.8), tibble, webfakes, diff --git a/NAMESPACE b/NAMESPACE index 4531d624..20848954 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,6 +6,7 @@ S3method("[[",httr2_headers) S3method(print,httr2_cmd) S3method(print,httr2_headers) S3method(print,httr2_oauth_client) +S3method(print,httr2_oauth_shiny_client) S3method(print,httr2_obfuscated) S3method(print,httr2_request) S3method(print,httr2_response) @@ -45,6 +46,23 @@ export(oauth_flow_device) export(oauth_flow_password) export(oauth_flow_refresh) export(oauth_redirect_uri) +export(oauth_shiny_app) +export(oauth_shiny_app_example) +export(oauth_shiny_app_passphrase) +export(oauth_shiny_app_url) +export(oauth_shiny_client) +export(oauth_shiny_client_config) +export(oauth_shiny_client_github) +export(oauth_shiny_client_github_set_custom_claim) +export(oauth_shiny_client_google) +export(oauth_shiny_client_microsoft) +export(oauth_shiny_client_spotify) +export(oauth_shiny_client_spotify_set_custom_claim) +export(oauth_shiny_get_access_token) +export(oauth_shiny_get_app_token) +export(oauth_shiny_ui_button) +export(oauth_shiny_ui_login) +export(oauth_shiny_ui_logout) export(oauth_token) export(oauth_token_cached) export(obfuscate) diff --git a/R/oauth-shiny-app.R b/R/oauth-shiny-app.R new file mode 100644 index 00000000..2838f1e1 --- /dev/null +++ b/R/oauth-shiny-app.R @@ -0,0 +1,248 @@ +#' Integrate OAuth into a Shiny Application +#' +#' @description This function integrates OAuth-based authentication into Shiny +#' applications, managing the full OAuth authorization code flow including +#' token acquisition, storage, and session management. It supports two main +#' scenarios: +#' +#' 1. **Enforcing User Login**: Users must authenticate through an OAuth +#' provider before accessing the Shiny app. The login interface can be +#' automatically generated based on the `client_config` or provided via the +#' `login_ui` parameter. Alternatively, you can bypass the login UI and +#' redirect users directly to the OAuth client by setting `login_ui` to `NULL` +#' and configuring a primary authentication provider in the `client_config`. +#' This setup is useful in enterprise environments where seamless integration +#' with single sign-on (SSO) solutions is desired. +#' +#' 2. **Retrieving Tokens on Behalf of Users**: This functionality allows for +#' obtaining OAuth tokens from users, which can be used for accessing external +#' APIs. This can be applied whether or not user login is enforced. When +#' `require_auth = TRUE`, users must log in, and the tokens can be used in the +#' context of their authenticated session. When `require_auth = FALSE`, tokens +#' are retrieved from users in a public app setting where login is optional or +#' not enforced. In both scenarios, tokens are stored securely in encrypted +#' cookies and can be retrieved using `oauth_shiny_get_access_token()`. +#' +#' The function manages OAuth by setting two types of cookies: +#' - **App Cookie**: Contains a JSON Web Token (JWT) that holds user claims +#' such as `name`, `email`, `sub`, and `aud`. This cookie is used to maintain +#' the user's session in the Shiny app. It can be retrieved in a shiny app +#' using `oauth_shiny_get_app_token()` +#' - **Access Token Cookie**: If the `access_token_validity` for a client is +#' greater than 0, an additional cookie is created to store the OAuth access +#' token. This cookie is encrypted and can be retrieved using +#' `oauth_shiny_get_access_token()`. +#' +#' @param app A Shiny app object, typically created using [shiny::shinyApp()]. +#' For improved readability, consider using the pipe operator, e.g., +#' `shinyApp() |> oauth_shiny_app(...)`. +#' @param client_config An `oauth_shiny_config` object that specifies the OAuth +#' clients to be used. This object should include configurations for one or +#' more OAuth providers, created with `oauth_shiny_client_*()` functions. +#' @param require_auth Logical; determines whether user authentication is +#' mandatory before accessing the app. Set to `TRUE` to enforce login, which +#' will redirect unauthenticated users to the OAuth login UI. Set to `FALSE` +#' for a public app where login is optional but token retrieval is still +#' supported. Defaults to `TRUE`. +#' @param key The encryption key used to secure cookies containing +#' authentication information. This key should be a long, randomly generated +#' string. By default, it is retrieved from the environment variable +#' `HTTR2_OAUTH_PASSPHRASE`. You can generate a suitable key using +#' `httr2::secret_make_key()` or a similar method. +#' @param dark_mode Logical; specifies whether the login and logout user +#' interfaces should use a dark mode theme. If `TRUE`, the interfaces will +#' adopt a dark color scheme. Defaults to `FALSE`. +#' @param login_ui The user interface displayed to users for login when +#' `require_auth = TRUE`. By default, this is automatically generated based on +#' the OAuth clients specified in `client_config`. You can provide a custom UI +#' if desired. +#' @param logout_ui The user interface shown to users for logout. By default, +#' this UI is automatically generated based on the OAuth clients in +#' `client_config`. You can provide a custom UI to override the default +#' behavior. +#' @param logout_path The URL path used to handle user logout requests. Users +#' will be redirected to this path to log out of the application. Defaults to +#' `'logout'`. If you wish to customize the logout path, specify it here. +#' @param logout_on_token_expiry Logical; determines if users should be +#' automatically logged out when the app token expires. If `TRUE`, the user +#' session will end when the token expires. If `FALSE`, the session remains +#' active until the user manually logs out or refreshes the browser. Defaults +#' to `FALSE`. +#' @param cookie_name The name of the cookie used to store authentication +#' information. This cookie holds the app token containing user claims. +#' Defaults to `'oauth_app_token'`. You can specify a different name if +#' needed. +#' @param token_validity Numeric; the duration in seconds for which the user's +#' session remains valid. This controls how long the JWT or access token is +#' valid before it expires. Defaults to `3600` seconds (1 hour). +#' +#' @export +oauth_shiny_app <- function( + app, + client_config, + require_auth = TRUE, + key = oauth_shiny_app_passphrase(), + dark_mode = FALSE, + login_ui = oauth_shiny_ui_login(client_config, dark_mode), + logout_ui = oauth_shiny_ui_logout(client_config, dark_mode), + logout_path = "logout", + logout_on_token_expiry = FALSE, + cookie_name = "oauth_app_token", + token_validity = 3600) { + # This function takes the app object and transforms/decorates it to create a + # new app object. The new app object will wrap the original ui/server with + # authentication logic, so that the original ui/server is not invoked unless + # and until the user has an app token from an auth provider if `require_auth` + # is `TRUE`. + + check_installed("jose") + check_installed("sodium") + + # Force and normalize arguments + force(app) + force(client_config) + force(login_ui) + force(logout_ui) + + if (is.null(key) || is.na(key) || key == "") { + cli::cli_abort("Must supply either {.arg key} or set environment variable {.arg HTTR2_OAUTH_PASSPHRASE}") + } else if (nchar(key) < 16) { + cli::cli_alert_warning("You are using a key of less than 16 characters") + } + + # Override the HTTP handler, which is the "front door" through which a browser + # comes to the Shiny app. + httpHandler <- app$httpHandler + app$httpHandler <- function(req) { + # Each handle_* function will decide if it can handle the request, based on + # the URL path, request method, presence/absence/validity of cookies, etc. + # The return value will be NULL if the `handle` function couldn't handle the + # request, and either HTML tag objects or a shiny::httpResponse if it + # decided to handle it. + resp <- + # The logout_path revokes all app and access tokens and deletes cookies + handle_oauth_app_logout(req, client_config, logout_path, cookie_name, logout_ui) %||% + # The client logout_path revokes a single access token and deletes cookies + handle_oauth_client_logout(req, client_config, require_auth, cookie_name, key) %||% + # The client login_path handles redirection to the specific client + handle_oauth_client_login(req, client_config, require_auth, cookie_name, key) %||% + # Handles callback from oauth client (after login) + handle_oauth_client_callback(req, client_config, require_auth, cookie_name, key, token_validity) %||% + # Handles requests that have good cookies or does not require auth + handle_oauth_app_logged_in(req, client_config, require_auth, cookie_name, key, httpHandler) %||% + # If we get here, the user isn't logged in + handle_oauth_app_login(req, client_config, login_ui) + + resp + } + + # Only invoke the provided server logic if the user is logged in; and make the + # token automatically available within the server logic + serverFuncSource <- app$serverFuncSource + app$serverFuncSource <- function() { + wrappedServer <- serverFuncSource() + function(input, output, session) { + token <- oauth_shiny_get_app_token(cookie_name, key) + if (is.null(token) && require_auth) { + cli::cli_abort("No valid OAuth token was found on the websocket connection") + return(NULL) + } else { + if (require_auth && logout_on_token_expiry) { + # Since Shiny can only request cookies at the start up of the app, the + # cookie can be expired when the user is active beyond the cookie + # lifetime. In this case, we can force a refresh of the app which will + # ensure that the cookie is no longer available. This can appear + # unfriendly for the user who will be immediately redirected back to + # the login screen but until we have a clear strategy for how token + # refresh should work, this seems like a good temporary solution. + expiry_time <- ceiling(token[["exp"]] + 1 - unix_time()) * 1000 + token_expired <- shiny::reactiveTimer(expiry_time) + shiny::observeEvent(token_expired(), session$reload(), ignoreInit = TRUE) + } + wrappedServer(input, output, session) + } + } + } + + onStart <- app$onStart + app$onStart <- function() { + # Call original onStart, if any + if (is.function(onStart)) { + onStart() + } + } + + app +} + +#' Extract server URL from the request +#' +#' @description Inferring the correct app url on the server requires some work. +#' This function attempts to guess the correct server url, but may fail outside +#' of tested hosts (`127.0.0.1` and `shinyapps.io`). To be sure, set the +#' environment variable `HTTR2_OAUTH_APP_URL` explicitly. Logic inspired by +#' [https://github.com/r4ds/shinyslack](r4ds/shinyslack). +#' @param req A request object. +#' +#' @return The app url. +#' @keywords internal + +oauth_shiny_infer_app_url <- function(req) { + if (!is.na(oauth_shiny_app_url())) { + return(oauth_shiny_app_url()) + } + + if (any( + c("x-redx-frontend-name", "http_x_redx_frontend_name") + %in% tolower(names(req)) + )) { + url <- req$HTTP_X_REDX_FRONTEND_NAME %||% + req$http_x_redx_frontend_name %||% + req$`X-REDX-FRONTEND-NAME` %||% + req$`x-redx-frontend-name` + + scheme <- req$HTTP_X_FORWARDED_PROTO %||% + req$http_x_forwarded_proto %||% + req$`X-FORWARDED-PROTO` %||% + req$`x-forwarded-proto` + } else { + url <- req$SERVER_NAME %||% req$server_name + + if (is.null(url)) { + cli::cli_abort( + message = c(x = "Could not determine url.") + ) + } + + port <- req$SERVER_PORT %||% req$server_port + + if (!is.null(port)) { + url <- paste(url, port, sep = ":") + } + + scheme <- req$rook.url_scheme + } + + url <- paste0(scheme, "://", url) + url <- sub("\\?.*", "", url) + url +} + +#' Override app url for OAuth +#' +#' It can be difficult to correctly infer the correct app url depending on +#' which environment the app is running in (localhost, shinyapps, cloud, etc). +#' httr2 makes an attempt to guess the correct app url, but the environment +#' variable `HTTR2_OAUTH_APP_URL` could be used to override a wrong guess. +#' +#' @export +oauth_shiny_app_url <- function() { + Sys.getenv("HTTR2_OAUTH_APP_URL", NA_character_) +} + +#' Default passphrase +#' +#' @export +oauth_shiny_app_passphrase <- function() { + Sys.getenv("HTTR2_OAUTH_PASSPHRASE", NA_character_) +} diff --git a/R/oauth-shiny-client.R b/R/oauth-shiny-client.R new file mode 100644 index 00000000..568d230d --- /dev/null +++ b/R/oauth-shiny-client.R @@ -0,0 +1,488 @@ +#' Create an OAuth Shiny Client +#' +#' The OAuth Shiny Client object allows you to use an oauth client in a shiny +#' application and supports two scenarios: (i) Restricting access to a shiny +#' application and (ii) performing actions on behalf of the user (behind an open +#' or restricted shiny application). If the OAuth Provider follows the [OpenID +#' specification](https://openid.net/specs/openid-connect-core-1_0.html) , the +#' `openid_issuer_url` can be used to retrieve endpoints and keys to perform and +#' verify the OAuth dance. If the provider does not support OpenID (e.g. +#' Github), then the user needs to figure this out on its own and provide +#' necessary endpoints (e.g `auth_url` and `token_url`) themselves. +#' +#' @param name The name of the OAuth Provider. Used to provide an easy reference +#' to the client. Also used to derive the name of the login-endpoint (e.g. +#' /login/github), client cookie name (e.g. oauth_app_github_token). Should be +#' a valid URI path. +#' @param id Client identifier +#' @param secret Client secret. For most apps, this is technically confidential +#' so in principle you should avoid storing it in source code. However, many +#' APIs require it in order to provide a user friendly authentication +#' experience, and the risks of including it are usually low. To make things a +#' little safer, I recommend using [obfuscate()] when recording the client +#' secret in public code. +#' @param auth_url Authorization url. If the client does not follow the OpenID +#' specification you'll need to discover this by reading the documentation. +#' Otherwise, you could supply the `openid_issuer_url`. Supplying an +#' `auth_url` will take precedence over any urls discovered by +#' `openid_issuer_url`. +#' @param token_url Url to retrieve an access token. +#' @param redirect_uri URL to redirect back to after authorization is complete. +#' Often this must be registered with the API in advance. +#' @param openid_issuer_url If the provider follows the openid specification, +#' provide the issuer url. Do not include the path to the configuration +#' endpoint (also known as Well-Known Configuration Endpoint). Examples +#' include +#' `https://login.microsoftonline.com/TENANT/v2.0` +#' (microsoft) and `https://accounts.google.com/` (google). +#' @param openid_claims Character vector of claims to be retrieved if +#' `openid_issuer_url` is not NULL. These claims will be included in the app +#' token to easily retrieve user information in shiny. Defaults to `name`, +#' `email`, `aud` and `sub`. See Section 5.1. Standard Claims of +#' [OpenID spec](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). +#' @param scope Scopes to be requested from the resource owner. +#' @param auth_params A list containing additional parameters passed to +#' [oauth_flow_auth_code_url()]. +#' @param token_params List containing additional parameters passed to the +#' `token_url`. +#' @param pkce Use "Proof Key for Code Exchange"? This adds an extra layer of +#' security and should always be used if supported by the server. +#' @param postprocess_token A custom function for postprocessing the token, e.g. +#' token exchange or verification +#' @param auth_provider Whether the OAuth client should be used to restrict +#' access to the shiny application. An OAuth App could often have multiple +#' auth providers (e.g. Apple, and Google), sometimes described as "social +#' login". If set to `TRUE`, this will generate login buttons for the client +#' when the standard UIs for login (`oauth_shiny_ui_login()`) and logout are +#' used. If set to `FALSE`, the OAuth Client is not used to restrict access to +#' the app, but could still be used to perform actions against the OAuth +#' provider when the user has succesfully logged in to the application. +#' @param auth_provider_primary If the user has a preferred provider, this +#' could be used to automatically redirect the user to follow the OAuth dance +#' without displaying a UI for logigng in. A typical scenario could be an +#' enterprise application where the a non authorized user is always redirected +#' to login at a single identity provider (e.g. Microsoft Entra). Set +#' `login_ui` to NULL to enable direct login +#' @param auth_set_custom_claim If the OAuth provider does not follow the +#' openid specification, a custom function could be used to set the audience +#' (aud) and subject (sub) for the app token. This function should take +#' `client` and `token` as input arguments. See +#' `oauth_shiny_client_github_set_custom_claim()` +#' @param login_button A button typically generated by +#' `oauth_shiny_ui_button` which can be used in the login UI +#' @param login_button_dark A button typically generated by +#' `oauth_shiny_ui_button` which can be used in the login UI (dark +#' theme) +#' @param login_path Endpoint where users will be redirected to login for the +#' client, e.g. `/login/github`. Typically not overridden. +#' @param logout_path Endpoint where users will be redirected to login for the +#' client, e.g. `/logout/github` Typically not overridden. +#' @param client_cookie_name The name of the client cookie which contains the +#' access_token (and potentially id_token) for the client +#' @param access_token_validity Validity duration for the client cookie which +#' contains the access token. If you want to make the client token available +#' in the application, set this to a value > `0`, e.g. `3600`. If set to +#' `NULL` the cookie will have the same validity as the token if specified and +#' will be unavailable if the token does not contain an expiry time (e.g. +#' Github). Regardless of the value set, the cookie will not expire later than +#' the token itself expires +#' @export +oauth_shiny_client <- function( + name, + id, + secret = NULL, + auth_url = NULL, + token_url = NULL, + redirect_uri = oauth_redirect_uri(), + openid_issuer_url = NULL, + openid_claims = c("aud", "sub", "name", "email"), + scope = NULL, + auth_params = list(), + token_params = list(), + pkce = TRUE, + postprocess_token = NULL, + auth_provider = FALSE, + auth_provider_primary = FALSE, + auth_set_custom_claim = NULL, + login_button = NULL, + login_button_dark = NULL, + login_path = paste0("login/", name), + logout_path = paste0("logout/", name), + client_cookie_name = paste0("oauth_app_", name, "_token"), + access_token_validity = 0) { + if (!grepl("^[a-zA-Z][a-zA-Z0-9_]*$", name)) { + cli::cli_abort("{.arg name} should only contain letters and underscores") + } + if (is.null(auth_url) & is.null(openid_issuer_url)) { + cli::cli_abort("OAuth Client requires either {.arg auth_url} or {.arg openid_issuer_url}") + } + if (is.null(token_url) & is.null(openid_issuer_url)) { + cli::cli_abort("OAuth Client requires either {.arg token_url} or {.arg openid_issuer_url}") + } + + structure( + list( + name = name, + id = id, + secret = secret, + auth_url = auth_url, + token_url = token_url, + redirect_uri = redirect_uri, + openid_issuer_url = openid_issuer_url, + openid_claims = openid_claims, + scope = scope, + auth_params = auth_params, + token_params = token_params, + pkce = pkce, + postprocess_token = postprocess_token, + auth_provider = auth_provider, + auth_provider_primary = auth_provider_primary, + auth_set_custom_claim = auth_set_custom_claim, + login_button = login_button, + login_button_dark = login_button_dark, + login_path = login_path, + logout_path = logout_path, + client_cookie_name = client_cookie_name, + access_token_validity = access_token_validity + ), + class = "httr2_oauth_shiny_client" + ) +} + +#' @export +print.httr2_oauth_shiny_client <- function(x, ...) { + cli::cli_text(cli::style_bold("<", paste(class(x), collapse = "/"), ">")) + redacted <- list_redact(compact(x), c("secret", "key")) + redacted <- redacted[map_lgl(redacted, is.atomic)] + cli::cli_dl(redacted) + invisible(x) +} + +#' Configure Multiple OAuth Shiny Clients +#' +#' This function allows you to configure multiple OAuth Shiny Clients by +#' combining them into a single list. This is useful when your Shiny application +#' supports multiple OAuth providers (e.g., Google, Microsoft, GitHub, etc.). +#' +#' Each client in the list must be an object created by the +#' `oauth_shiny_client()` function or one of its specialized versions (e.g., +#' `oauth_shiny_client_google()`). +#' +#' @param ... A series of OAuth Shiny Client objects created by +#' `oauth_shiny_client()` or its specialized versions. +#' +#' @return A named list of OAuth Shiny Client objects. The names of the list +#' elements correspond to the `name` parameter of each client. +#' +#' @examples +#' \dontrun{ +#' google_client <- oauth_shiny_client_google() +#' microsoft_client <- oauth_shiny_client_microsoft() +#' config <- oauth_shiny_client_config(google_client, microsoft_client) +#' } +#' +#' @export +oauth_shiny_client_config <- function(...) { + is_oauth_shiny_client <- function(x) class(x) == "httr2_oauth_shiny_client" + if (!all(map_lgl(list(...), is_oauth_shiny_client))) { + cli::cli_abort("Input to {.fun oauth_shiny_client_config} must be a list + of clients generated by {.fun oauth_shiny_client}") + } + set_names(list(...), map(list(...), function(client) client$name)) +} + + +#' Create an OAuth Shiny Client for Microsoft +#' +#' This function creates an OAuth Shiny Client specifically for Microsoft. It +#' uses the general `oauth_shiny_client` function to set up the client with +#' Microsoft-specific defaults. +#' +#' For parameter details, see [oauth_shiny_client()]. +#' +#' @param name The name of the OAuth Provider, default is "microsoft". +#' @param id Client ID, defaults to environment variable +#' `OAUTH_MICROSOFT_CLIENT_ID`. +#' @param secret Client Secret, defaults to environment variable +#' `OAUTH_MICROSOFT_CLIENT_SECRET`. +#' @param openid_issuer_url URL for OpenID issuer. +#' @param scope Scopes to be requested, defaults to "openid profile email". +#' @param login_button Button used for Microsoft login, defaults to +#' `oauth_shiny_ui_button_microsoft()`. +#' @param login_button_dark Dark theme button for Microsoft login, defaults to +#' `oauth_shiny_ui_button_microsoft_dark()`. +#' @param ... Additional arguments passed to `oauth_shiny_client()`. +#' +#' @return A Shiny OAuth Client configured for Microsoft. +#' @export +#' +#' @examples +#' \dontrun{ +#' microsoft_client <- oauth_shiny_client_microsoft() +#' } +#' +oauth_shiny_client_microsoft <- function( + name = "microsoft", + id = Sys.getenv("OAUTH_MICROSOFT_CLIENT_ID"), + secret = Sys.getenv("OAUTH_MICROSOFT_CLIENT_SECRET"), + openid_issuer_url, + scope = "openid profile email", + login_button = oauth_shiny_ui_button_microsoft(), + login_button_dark = oauth_shiny_ui_button_microsoft_dark(), + ...) { + oauth_shiny_client( + name = name, + id = id, + secret = secret, + openid_issuer_url = openid_issuer_url, + scope = scope, + login_button = login_button, + login_button_dark = login_button_dark, + ... + ) +} + +#' Create an OAuth Shiny Client for Google +#' +#' This function creates an OAuth Shiny Client specifically for Google. It uses +#' the general `oauth_shiny_client` function to set up the client with +#' Google-specific defaults. +#' +#' For parameter details, see [oauth_shiny_client()]. +#' +#' @param name The name of the OAuth Provider, default is "google". +#' @param id Client ID, defaults to environment variable +#' `OAUTH_GOOGLE_CLIENT_ID`. +#' @param secret Client Secret, defaults to environment variable +#' `OAUTH_GOOGLE_CLIENT_SECRET`. +#' @param openid_issuer_url URL for OpenID issuer, defaults to +#' "https://accounts.google.com/". +#' @param scope Scopes to be requested, defaults to "openid profile email". +#' @param login_button Button used for Google login, defaults to +#' `oauth_shiny_ui_button_google()`. +#' @param login_button_dark Dark theme button for Google login, defaults to +#' `oauth_shiny_ui_button_google_dark()`. +#' @param ... Additional arguments passed to `oauth_shiny_client()`. +#' +#' @return A Shiny OAuth Client configured for Google. +#' @export +#' +#' @examples +#' \dontrun{ +#' google_client <- oauth_shiny_client_google() +#' } +#' +oauth_shiny_client_google <- function( + name = "google", + id = Sys.getenv("OAUTH_GOOGLE_CLIENT_ID"), + secret = Sys.getenv("OAUTH_GOOGLE_CLIENT_SECRET"), + openid_issuer_url = "https://accounts.google.com/", + scope = "openid profile email", + login_button = oauth_shiny_ui_button_google(), + login_button_dark = oauth_shiny_ui_button_google_dark(), + ...) { + oauth_shiny_client( + name = name, + id = id, + secret = secret, + openid_issuer_url = openid_issuer_url, + scope = scope, + login_button = login_button, + login_button_dark = login_button_dark, + ... + ) +} + +#' Create a Github OAuth Client in Shiny +#' +#' This function creates an OAuth Shiny Client specifically for GitHub. It uses +#' the general `oauth_shiny_client` function to set up the client with +#' GitHub-specific defaults. Note that Github does not currently support openid, +#' hence it requires additional requests to `/user/email` to authorize the user. +#' Create the app from your [Developer +#' Settings](https://github.com/settings/developers) +#' +#' @param name The name of the OAuth Provider, default is "github". +#' @param id Client ID, defaults to environment variable +#' `OAUTH_GITHUB_CLIENT_ID`. +#' @param secret Client Secret, defaults to environment variable +#' `OAUTH_GITHUB_CLIENT_SECRET`. +#' @param redirect_uri Authorization callback URL, defaults to +#' `oauth_redirect_uri()`. +#' @param scope Scopes to be requested, defaults to "read:user user:email". +#' @param login_button Button used for GitHub login, defaults to +#' `oauth_shiny_ui_button_github()`. +#' @param login_button_dark Dark theme button for GitHub login, defaults to +#' `oauth_shiny_ui_button_github_dark()`. +#' @param auth_set_custom_claim Custom function for setting claims, defaults +#' to `oauth_shiny_client_github_set_custom_claim`. +#' @param ... Additional arguments passed to `oauth_shiny_client()`. +#' +#' @return A Shiny OAuth Client configured for GitHub. +#' @export +#' +#' @examples +#' \dontrun{ +#' spotify_client <- oauth_shiny_client_spotify() +#' } +#' +oauth_shiny_client_github <- function( + name = "github", + id = Sys.getenv("OAUTH_GITHUB_CLIENT_ID"), + secret = Sys.getenv("OAUTH_GITHUB_CLIENT_SECRET"), + redirect_uri = oauth_redirect_uri(), + scope = "read:user user:email", + login_button = oauth_shiny_ui_button_github(), + login_button_dark = oauth_shiny_ui_button_github_dark(), + auth_set_custom_claim = oauth_shiny_client_github_set_custom_claim, + ...) { + oauth_shiny_client( + name = name, + id = id, + secret = secret, + auth_url = "https://github.com/login/oauth/authorize", + token_url = "https://github.com/login/oauth/access_token", + pkce = FALSE, + redirect_uri = redirect_uri, + scope = scope, + login_button = login_button, + login_button_dark = login_button_dark, + auth_set_custom_claim = auth_set_custom_claim, + ... + ) +} + +#' This function creates an OAuth Shiny Client specifically for Spotify. It uses +#' the general `oauth_shiny_client` function to set up the client with +#' Spotify-specific defaults. +#' +#' For parameter details, see [oauth_shiny_client()]. +#' +#' @param name The name of the OAuth Provider, default is "spotify". +#' @param id Client ID, defaults to environment variable +#' `OAUTH_SPOTIFY_CLIENT_ID`. +#' @param secret Client Secret, defaults to environment variable +#' `OAUTH_SPOTIFY_CLIENT_SECRET`. +#' @param redirect_uri Authorization callback URL, defaults to +#' `oauth_redirect_uri()`. +#' @param scope Scopes to be requested, defaults to "user-read-email". +#' @param login_button Button used for Spotify login, defaults to +#' `oauth_shiny_ui_button_spotify()`. +#' @param login_button_dark Dark theme button for Spotify login, defaults to +#' `oauth_shiny_ui_button_spotify_dark()`. +#' @param auth_set_custom_claim Custom function for setting claims, defaults +#' to `oauth_shiny_client_spotify_set_custom_claim`. +#' @param ... Additional arguments passed to `oauth_shiny_client()`. +#' +#' @return A Shiny OAuth Client configured for Spotify. +#' @export +#' +#' @examples +#' \dontrun{ +#' spotify_client <- oauth_shiny_client_spotify() +#' } +oauth_shiny_client_spotify <- function( + name = "spotify", + id = Sys.getenv("OAUTH_SPOTIFY_CLIENT_ID"), + secret = Sys.getenv("OAUTH_SPOTIFY_CLIENT_SECRET"), + redirect_uri = oauth_redirect_uri(), + scope = "user-read-email", + login_button = oauth_shiny_ui_button_spotify(), + login_button_dark = oauth_shiny_ui_button_spotify_dark(), + auth_set_custom_claim = oauth_shiny_client_spotify_set_custom_claim, + ...) { + oauth_shiny_client( + name = name, + id = id, + secret = secret, + auth_url = "https://accounts.spotify.com/authorize", + token_url = "https://accounts.spotify.com/api/token", + pkce = TRUE, + redirect_uri = redirect_uri, + scope = scope, + login_button = login_button, + login_button_dark = login_button_dark, + auth_set_custom_claim = auth_set_custom_claim, + ... + ) +} + +#' Set Custom Claims for GitHub OAuth Client +#' +#' This function retrieves user information from GitHub and sets custom claims +#' for the OAuth token. The claims include the user's primary email and name. +#' +#' This function is typically used with the GitHub OAuth client created by +#' `oauth_shiny_client_github`. +#' +#' @param client An OAuth Shiny Client object created by +#' `oauth_shiny_client_github`. +#' @param token The OAuth token object containing `access_token`. +#' +#' @return A list of custom claims, including `sub` (email), `name` (user's +#' name), and `provider` (provider name, "github.com"). +#' @export +#' +#' @examples +#' \dontrun{ +#' claims <- oauth_shiny_client_github_set_custom_claim(client, token) +#' } +#' +oauth_shiny_client_github_set_custom_claim <- function(client, token) { + req <- request("https://api.github.com/user/emails") + req <- req_auth_bearer_token(req, token$access_token) + req <- req_perform(req) + resp <- resp_body_json(req) + + email_primary_idx <- which(as.logical(lapply(resp, function(x) x$primary))) + email <- resp[[email_primary_idx]][["email"]] + + req <- request("https://api.github.com/user") + req <- req_auth_bearer_token(req, token$access_token) + req <- req_perform(req) + resp <- resp_body_json(req) + name <- resp$name + + claims <- list( + sub = email, + aud = "github.com", + name = name + ) + claims +} + +#' Set Custom Claims for Spotify OAuth Client +#' +#' This function retrieves user information from Spotify and sets custom claims +#' for the OAuth token. The claims include the user's email and display name. +#' +#' This function is typically used with the Spotify OAuth client created by +#' `oauth_shiny_client_spotify`. +#' +#' @param client An OAuth Shiny Client object created by +#' `oauth_shiny_client_spotify`. +#' @param token The OAuth token object containing `access_token`. +#' +#' @return A list of custom claims, including `sub` (email), `aud` (audience, +#' "spotify.com"), and `name` (display name). +#' @export +#' +#' @examples +#' \dontrun{ +#' # Assuming `spotify_client` is created with `oauth_shiny_client_spotify()` +#' claims <- oauth_shiny_client_spotify_set_custom_claim(spotify_client, token) +#' } +#' +oauth_shiny_client_spotify_set_custom_claim <- function(client, token) { + req <- request("https://api.spotify.com/v1/me") + req <- req_auth_bearer_token(req, token$access_token) + req <- req_perform(req) + resp <- resp_body_json(req) + + claims <- list( + sub = resp$email, + aud = "spotify.com", + name = resp$display_name + ) + claims +} diff --git a/R/oauth-shiny-cookies.R b/R/oauth-shiny-cookies.R new file mode 100644 index 00000000..73859c86 --- /dev/null +++ b/R/oauth-shiny-cookies.R @@ -0,0 +1,122 @@ +oauth_shiny_set_cookie_header <- function(name, value, cookie_opts) { + # Chunk cookies if it exceeds 4000 characters to comply with max size 4096 + if (nchar(value) <= 4000) { + cookie_hdr <- set_cookie_header(name, value, cookie_opts) + } else { + values <- strsplit(value, "(?<=.{4000})(?=.)", perl = TRUE)[[1]] + names <- paste(name, seq(length(values)), sep = "_") + cookie_hdr <- map2(names, values, set_cookie_header, cookie_opts) + } + unlist(cookie_hdr) +} + +################# # +# Gargle Cookie Functions +# From: https://github.com/r-lib/gargle/pull/157/ +# Remains unchanged, only changed standard args for cookie options (samesite, secure, httponly) +################# # + +parse_cookies <- function(req) { + cookie_header <- req[["HTTP_COOKIE"]] + if (is.null(cookie_header)) { + return(NULL) + } + + cookies <- strsplit(cookie_header, "; *")[[1]] + m <- regexec("(.*?)=(.*)", cookies) + matches <- regmatches(cookies, m) + names <- vapply(matches, function(x) { + if (length(x) == 3) { + x[[2]] + } else { + "" + } + }, character(1)) + + if (any(names == "")) { + # Malformed cookie + return(NULL) + } + + values <- vapply(matches, function(x) { + x[[3]] + }, character(1)) + + set_names(as.list(values), names) +} + +cookie_options <- function(max_age = NULL, domain = NULL, path = "/", + secure = TRUE, http_only = TRUE, same_site = "lax", expires = NULL) { + if (!is.null(expires)) { + stopifnot(length(expires) == 1 && (inherits(expires, "POSIXt") || is.character(expires))) + if (inherits(expires, "POSIXt")) { + expires <- as.POSIXlt(expires, tz = "GMT") + expires <- sprintf( + "%s, %02d %s %04d %02d:%02d:%02.0f GMT", + c("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")[[expires$wday + 1]], + expires$mday, + c("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")[[expires$mon + 1]], + expires$year + 1900, + expires$hour, + expires$min, + expires$sec + ) + } + } + + stopifnot(is.null(max_age) || (is.numeric(max_age) && length(max_age) == 1)) + if (!is.null(max_age)) { + max_age <- sprintf("%.0f", max_age) + } + stopifnot(is.null(domain) || (is.character(domain) && length(domain) == 1)) + stopifnot(is.null(path) || (is.character(path) && length(path) == 1)) + stopifnot(is.null(secure) || isTRUE(secure) || isFALSE(secure)) + if (isFALSE(secure)) { + secure <- NULL + } + stopifnot(is.null(http_only) || isTRUE(http_only) || isFALSE(http_only)) + if (isFALSE(http_only)) { + http_only <- NULL + } + + stopifnot(is.null(same_site) || (is.character(same_site) && length(same_site) == 1 && + grepl("^(strict|lax|none)$", same_site, ignore.case = TRUE))) + # Normalize case + if (!is.null(same_site)) { + same_site <- c(strict = "Strict", lax = "Lax", none = "None")[[tolower(same_site)]] + } + list( + "Expires" = expires, + "Max-Age" = max_age, + "Domain" = domain, + "Path" = path, + "Secure" = secure, + "HttpOnly" = http_only, + "SameSite" = same_site + ) +} + +set_cookie_header <- function(name, value, cookie_options = cookie_options()) { + stopifnot(is.character(name) && length(name) == 1) + stopifnot(is.null(value) || (is.character(value) && length(value) == 1)) + value <- value %||% "" + + parts <- rlang::list2( + !!name := value, + !!!cookie_options + ) + parts <- parts[!vapply(parts, is.null, logical(1))] + + names <- names(parts) + sep <- ifelse(vapply(parts, isTRUE, logical(1)), "", "=") + values <- ifelse(vapply(parts, isTRUE, logical(1)), "", as.character(parts)) + header <- paste(collapse = "; ", paste0(names, sep, values)) + list("Set-Cookie" = header) +} + +# Returns a list, suitable for `!!!`-ing into a list of HTTP headers +delete_cookie_header <- function(name, cookie_options = cookie_options()) { + cookie_options[["Expires"]] <- NULL + cookie_options[["Max-Age"]] <- 0 + set_cookie_header(name, "", cookie_options) +} diff --git a/R/oauth-shiny-example.R b/R/oauth-shiny-example.R new file mode 100644 index 00000000..eeb1c519 --- /dev/null +++ b/R/oauth-shiny-example.R @@ -0,0 +1,362 @@ +format_duration <- function(seconds) { + days <- seconds %/% (24 * 3600) + seconds <- seconds %% (24 * 3600) + hours <- seconds %/% 3600 + seconds <- seconds %% 3600 + minutes <- seconds %/% 60 + seconds <- seconds %% 60 + + # Create the formatted string + result <- c() + if (days > 0) result <- c(result, paste0(days, " day", ifelse(days > 1, "s", ""))) + if (hours > 0) result <- c(result, paste0(hours, " hour", ifelse(hours > 1, "s", ""))) + if (minutes > 0) result <- c(result, paste0(minutes, " min", ifelse(minutes > 1, "s", ""))) + if (seconds > 0 || length(result) == 0) result <- c(result, paste0(seconds, " sec", ifelse(seconds > 1, "s", ""))) + + return(paste(result, collapse = ", ")) +} + +format_additional_token_information <- function(token, redact) { + if (is.null(token())) { + return(NULL) + } + + nms_reserved <- c("access_token", "cookie_expires_at", "expires_at") + nms <- !(names(token()) %in% nms_reserved) + + info <- subset(token(), nms) + + map2(info, names(info), function(value, name) { + value <- if (redact & grepl("_token$", name)) redact_text(value) else value + shiny::div( + class = "list-group-item bg-light", + shiny::h6(class = "my-0 overflow-hidden", value), + shiny::tags$small(class = "text-muted", name) + ) + }) +} + +redact_text <- function(value, symbol = "*", keep_characters = 3) { + paste0( + substr(value, 1, keep_characters), + strrep(symbol, nchar(value) - keep_characters) + ) +} + +oauth_shiny_app_example_client_mod_ui <- function(id) { + ns <- shiny::NS(id) + shiny::div( + class = "list-group text-start", + shiny::div( + class = "list-group-item d-flex align-items-center justify-content-between", + shiny::div( + shiny::div(class = "d-inline", shiny::uiOutput(ns("icon"))), + shiny::div(class = "d-inline mx-2", shiny::textOutput(ns("client_name"), inline = TRUE)), + ), + shiny::uiOutput(ns("button")) + ), + shiny::uiOutput(ns("token")), + shiny::uiOutput(ns("token_expires")), + shiny::uiOutput(ns("cookie_expires")), + shiny::uiOutput(ns("token_additional")) + ) +} + +oauth_shiny_app_example_client_mod_server <- function(id, client, key, redact) { + shiny::moduleServer(id, function(input, output, session) { + token <- shiny::reactive({ + client_token <- oauth_shiny_get_access_token(client, key) + if (!is.null(client_token)) { + # Cookies are only accessible to shiny on load and remain until refresh + # if the session is kept alive. Invalidate tokens here + cookie_expiry <- client_token[["cookie_expires_at"]] - unix_time() + if (cookie_expiry > 0) { + shiny::invalidateLater(cookie_expiry * 1000) + client_token + } else { + NULL + } + } + }) + + output$icon <- shiny::renderUI({ + class <- if (is.null(token())) "text-secondary" else "text-primary" + + shiny::icon("circle", class = paste("fas", class)) + }) + + output$client_name <- shiny::renderText(client$name) + + output$button <- shiny::renderUI({ + text <- "Log in" + href <- client$login_path + class <- "btn btn-sm btn-light" + + if (!is.null(token())) { + text <- "Log out" + href <- client$logout_path + } + + shiny::a(shiny::span(text), href = href, class = class) + }) + + output$token <- shiny::renderUI({ + list_group_class <- "bg-light disabled" + title <- "Not logged in" + + if (!is.null(token())) { + token_value <- token()[["access_token"]] + title <- if (redact) redact_text(token_value) else token_value + list_group_class <- "" + } + + shiny::div( + class = paste("list-group-item border-top-0 border-top-0", list_group_class), + shiny::div(class = "my-0 overflow-hidden", title), + shiny::tags$small(class = "opacity-75", "Token") + ) + }) + + output$cookie_expires <- shiny::renderUI({ + cookie_expires_value <- "No cookie" + list_group_class <- "bg-light disabled" + + if (!is.null(token())) { + cookie_expires <- token()[["cookie_expires_at"]] - unix_time() + if (cookie_expires > 0) { + shiny::invalidateLater(1000) + cookie_expires_value <- format_duration(cookie_expires) + list_group_class <- "" + } + } + + shiny::div( + class = paste(class = "list-group-item border-top-0 border-bottom-0", list_group_class), + shiny::div(class = "my-0 overflow-hidden", cookie_expires_value), + shiny::tags$small(class = "opacity-75", "Cookie expires") + ) + }) + + + output$token_expires <- shiny::renderUI({ + token_expires_value <- "Not logged in" + list_group_class <- "bg-light disabled" + + if (!is.null(token())) { + token_expires <- token()[["expires_at"]] + list_group_class <- "" + if (is.null(token_expires)) { + shiny::invalidateLater(1000) + token_expires_value <- "No expiry date" + } else if (token_expires > unix_time()) { + shiny::invalidateLater(1000) + token_expires_value <- format_duration(as.integer(token_expires - unix_time())) + } + } + + shiny::div( + class = paste(class = "list-group-item border-top-0", list_group_class), + shiny::div(class = "my-0 overflow-hidden", token_expires_value), + shiny::tags$small(class = "opacity-75", "Token expires") + ) + }) + + output$token_additional <- shiny::renderUI({ + info <- format_additional_token_information(token, redact) + + if (!is.null(info)) { + bslib::accordion( + class = "accordion-flush list-group-item m-0 p-0", + open = FALSE, + bslib::accordion_panel( + "Additional token info", + shiny::div(class = "list-group text-start", info) + ) + ) + } else { + # Manually create an accordion with a disabled look + style <- "padding: var(--bs-accordion-btn-padding-y); font-size: 1rem" + shiny::div( + class = "accordion accordion-flush list-group-item m-0 p-0", + shiny::div( + class = "accordion-item", + shiny::div( + class = "accordion-header bg-light", + shiny::div( + style = style, + shiny::div( + "No additional token info", + class = "accordion-title text-muted", + ) + ) + ) + ) + ) + } + }) + }) +} + +#' Example Shiny App Using OAuth +#' +#' This function sets up a Shiny application that uses multiple OAuth providers +#' for authentication. The application can be configured to allow users to sign +#' in using various OAuth providers like Google, Microsoft, GitHub, and others. +#' +#' @param client_config A `oauth_shiny_config` object containing a list of OAuth +#' client configurations (`oauth_shiny_client` objects). +#' @param require_auth Logical; should login be enforced? Defaults to `FALSE`. +#' @param key The encryption key for securing cookies. Defaults to the +#' environment variable `HTTR2_OAUTH_PASSPHRASE`. It should be a long, +#' randomly generated string, which can be created using +#' `httr2::secret_make_key()` or a similar method. +#' @param dark_mode Logical; should the login and logout UI use dark mode? +#' Defaults to `FALSE`.. +#' @param login_ui The login UI to present to users when `enforce_login = TRUE`. +#' Defaults to an automatically generated UI based on the clients listed in +#' `client_config`. +#' @param logout_ui The logout UI to present to users. Defaults to an +#' automatically generated UI based on the providers listed in +#' `client_config`. +#' @param logout_path The URL path used to log users out of the app. Defaults to +#' `'logout'`. +#' @param logout_on_token_expiry Logical; should the user be automatically +#' logged out of the app when the token expires? If `FALSE`, the session +#' remains active until the user refreshes the browser or manually logs out. +#' Defaults to `FALSE`. +#' @param cookie_name The name of the cookie used for authentication. Defaults +#' to `'oauth_app_token'`. +#' @param token_validity Validity of the app token in seconds. Defaults to +#' 1 hour (3600s) after which the token and cookie expires. +#' @param redact Logical, whether to redact tokens in UI. Defaults to `TRUE`. +#' +#' @return A configured Shiny app object that allows users to authenticate using +#' the specified OAuth providers. +#' +#' @examples +#' \dontrun{ +#' config <- oauth_shiny_client_config( +#' oauth_shiny_client_github(), +#' oauth_shiny_client_google() +#' ) +#' +#' oauth_shiny_app_example(config, dark_mode = TRUE) +#' } +#' @export + +oauth_shiny_app_example <- function(client_config, + require_auth = TRUE, + key = oauth_shiny_app_passphrase(), + dark_mode = FALSE, + login_ui = oauth_shiny_ui_login(client_config, dark_mode), + logout_ui = oauth_shiny_ui_logout(client_config, dark_mode), + logout_path = "logout", + logout_on_token_expiry = FALSE, + cookie_name = "oauth_app_token", + token_validity = 3600, + redact = TRUE) { + theme_light <- bslib::bs_theme(primary = "#038053", secondary = "#ccc") + theme_dark <- bslib::bs_theme(bg = "#121212", fg = "white", primary = "#038053") + theme <- if (dark_mode) theme_dark else theme_light + + ui <- bslib::page_navbar( + theme = theme, + id = "navbar", + bslib::nav_panel( + "OAuth Example App", + shiny::div( + class = "d-flex align-items-center min-vh-100", + shiny::div( + class = "container col-md-10", + bslib::layout_column_wrap( + !!!map(names(client_config), oauth_shiny_app_example_client_mod_ui) + ) + ) + ) + ), + bslib::nav_spacer(), + position = "static-top", + underline = FALSE + ) + + server <- function(input, output, session) { + token <- shiny::reactive(oauth_shiny_get_app_token(cookie_name, key)) + + output$app_token_expiry <- shiny::renderText({ + shiny::req(token()) + cookie_expiry_time <- token()[["exp"]] - unix_time() + if (cookie_expiry_time > 0) { + shiny::invalidateLater(1000) + cookie_expiry <- format_duration(cookie_expiry_time) + } else { + cookie_expiry <- "Expired" + } + cookie_expiry + }) + + shiny::observeEvent(token(), { + name <- token()[["name"]] %||% token()[["email"]] %||% token()[["sub"]] + email <- token()[["email"]] %||% token()[["sub"]] + provider <- token()[["provider"]] %||% token()[["aud"]] + + bslib::nav_insert( + "navbar", + bslib::nav_menu( + title = shiny::tagList( + shiny::div( + class = "d-inline", + shiny::icon("user", class = "text-primary me-1", lib = "glyphicon"), + name + ) + ), + bslib::nav_item( + shiny::span( + class = "dropdown-item-text overflow-hidden", + shiny::icon("envelope", lib = "glyphicon", class = "me-1"), + email + ) + ), + bslib::nav_item( + shiny::span( + class = "dropdown-item-text overflow-hidden", + shiny::icon("home", lib = "glyphicon", class = "me-1"), + provider + ) + ), + bslib::nav_item( + shiny::span( + class = "dropdown-item-text overflow-hidden", + shiny::icon("time", lib = "glyphicon", class = "me-1"), + shiny::textOutput("app_token_expiry", inline = TRUE) + ) + ), + bslib::nav_item( + shiny::a( + shiny::icon("log-in", lib = "glyphicon", class = "me-1"), "Log out", + href = logout_path, + class = "dropdown-item-text" + ) + ) + ) + ) + }) + + map(client_config, function(client) { + oauth_shiny_app_example_client_mod_server(client$name, client, key, redact) + }) + } + + oauth_shiny_app( + shiny::shinyApp(ui, server), + client_config, + require_auth = require_auth, + key = key, + dark_mode = dark_mode, + login_ui = login_ui, + logout_ui = logout_ui, + logout_path = logout_path, + cookie_name = cookie_name, + logout_on_token_expiry = logout_on_token_expiry, + token_validity = token_validity + ) +} diff --git a/R/oauth-shiny-http-handlers.R b/R/oauth-shiny-http-handlers.R new file mode 100644 index 00000000..ff9adef1 --- /dev/null +++ b/R/oauth-shiny-http-handlers.R @@ -0,0 +1,411 @@ +#' Handle OAuth for Logged-in App Users +#' +#' This function processes OAuth requests for logged-in users of the app. It +#' checks for an existing OAuth token, and if none is found and authentication +#' is required, it returns `NULL`. Otherwise, it invokes the provided HTTP +#' handler to continue processing the request. +#' +#' @param req A `shiny` request object. +#' @param client_config A list of client configurations used for OAuth. +#' @param require_auth Logical, whether authentication is required. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param key A secret key used to encrypt and decrypt tokens. +#' @param httpHandler A function to handle HTTP requests after authentication. +#' +#' @keywords internal +#' @return An HTTP response object or `NULL` if authentication fails. +handle_oauth_app_logged_in <- function(req, client_config, require_auth, cookie, key, httpHandler) { + token <- oauth_shiny_get_app_token_from_request(req, cookie, key) + if (is.null(token) && require_auth) { + return(NULL) + } + + httpHandler(req) +} + +#' Handle OAuth Login for App +#' +#' This function handles the OAuth login process for the app. It checks if the +#' request path is the root ("/"), and if so, either redirects to the primary +#' authentication provider or displays a custom login UI. The custom UI can +#' include a message and login options. +#' +#' @param req A `shiny` request object. +#' @param client_config A list of client configurations used for OAuth. +#' @param login_ui A UI function or object that provides a custom login page. +#' +#' @return An HTTP response object or `NULL` if the request path is not the +#' root. +#' @keywords internal +handle_oauth_app_login <- function(req, client_config, login_ui) { + if (!isTRUE(req$PATH_INFO == "/")) { + return(NULL) + } + + # If no welcome ui is specified, redirect to primary auth provider directly + if (is.null(login_ui)) { + for (client in client_config) { + if (client$auth_provider_primary) { + resp <- shiny::httpResponse( + status = 307L, + headers = rlang::list2( + Location = client$login_path, + "Cache-Control" = "no-store" + ) + ) + return(resp) + } + } + } + # Otherwise render login ui + ui <- login_ui + if (inherits(ui, "httpResponse")) { + ui + } else { + html <- render_static_html_document(ui) + shiny::httpResponse( + status = 403L, + content = html, + headers = rlang::list2( + "Cache-Control" = "no-store" + ) + ) + } +} + +#' Handle OAuth Logout for App +#' +#' This function handles the logout process for the app. It deletes the relevant +#' cookies from the user's browser and returns an HTTP response that either +#' displays a logout UI or redirects the user directly. +#' +#' @param req A `shiny` request object. +#' @param client_config A list of client configurations used for OAuth. +#' @param logout_path The path that triggers the app's logout process. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param logout_ui A UI function or object that provides a custom logout page. +#' +#' @return An HTTP response object or `NULL` if the request path does not match +#' the logout path. +#' @keywords internal +handle_oauth_app_logout <- function(req, client_config, logout_path, cookie, logout_ui) { + if (sub("^/", "", req$PATH_INFO) != logout_path) { + return(NULL) + } + cookies_app <- names(parse_cookies(req)) + cookies_clients <- map_chr(client_config, function(x) x[["client_cookie_name"]]) + cookies_regex_match <- paste(c(cookie, cookies_clients), collapse = "|") + cookies_match <- cookies_app[grepl(cookies_regex_match, cookies_app)] + + cookies_del <- unlist(lapply(cookies_match, delete_cookie_header, cookie_options())) + + html <- render_static_html_document(logout_ui) + + shiny::httpResponse( + status = 307L, + content = html, + headers = rlang::list2( + !!!cookies_del + ) + ) +} +#' Handle OAuth Client Login +#' +#' This function initiates the OAuth login process for clients configured with +#' the app. It loops through the client configurations and redirects the user to +#' the appropriate OAuth authorization URL. +#' +#' @param req A `shiny` request object. +#' @param client_config A list of client configurations used for OAuth. +#' @param require_auth Logical, whether authentication is required. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param key A secret key used to encrypt and decrypt tokens. +#' +#' @return An HTTP response object or `NULL` if no client is matched. +#' @keywords internal +handle_oauth_client_login <- function(req, client_config, require_auth, cookie, key) { + for (client in client_config) { + resp <- handle_oauth_client_login_redirect(req, client, require_auth, cookie, key) + if (!is.null(resp)) { + return(resp) + } + } +} + +#' Handle OAuth Client Login Redirect +#' +#' This function is invoked when the user is redirected to a client login +#' endpoint set by `login_path`. It handles redirection to the OAuth +#' authorization URL for a specific client. It manages state and PKCE (if +#' enabled) by setting cookies when redirecting the user to the OAuth endpoint. +#' +#' @param req A `shiny` request object. +#' @param client A single client configuration object. +#' @param require_auth Logical, whether authentication is required. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param key A secret key used to encrypt and decrypt tokens. +#' +#' @return An HTTP response object or `NULL` if the request path does not match +#' the client's login path. +#' @keywords internal +handle_oauth_client_login_redirect <- function(req, client, require_auth, cookie, key) { + if (sub("^/", "", req$PATH_INFO) != client$login_path) { + return(NULL) + } + + # Don't accept request for non-auth login endpoints if user is not authenticated + has_auth <- !is.null(oauth_shiny_get_app_token_from_request(req, cookie, key)) + if(require_auth && !has_auth && !client$auth_provider) { + return(NULL) + } + + state <- paste0(client$id, base64_url_rand(32)) + auth_params <- client$auth_params + + # Handle PKCE + if (client$pkce) { + pkce_code <- oauth_flow_auth_code_pkce() + auth_params$code_challenge <- pkce_code$challenge + auth_params$code_challenge_method <- pkce_code$method + set_cookie_header_pkce <- set_cookie_header("oauth_pkce_verifier", pkce_code$verifier, cookie_options()) + } else { + set_cookie_header_pkce <- NULL + } + + # If openid, resolve urls if necessary and set issuer to avoid mixup attacks + if (!is.null(client$openid_issuer_url)) { + client <- oauth_shiny_client_openid_resolve_urls(client) + auth_params$issuer_url <- utils::URLencode(client$openid_issuer_url) + } + + auth_code_url <- oauth_flow_auth_code_url( + client = client, + auth_url = client$auth_url, + redirect_uri = client$redirect_uri, + scope = client$scope, + state = state, + auth_params = auth_params + ) + + shiny::httpResponse( + status = 307L, + headers = rlang::list2( + Location = auth_code_url, + "Cache-Control" = "no-store", + !!!set_cookie_header("oauth_state", state, cookie_options()), + !!!set_cookie_header_pkce + ) + ) +} + +#' Handle OAuth Client Callback +#' +#' This function processes the OAuth callback after the user has authorized the +#' app. It exchanges the authorization code for an access token, handles PKCE +#' verification, and sets cookies for both the app and the client access token. +#' +#' @param req A `shiny` request object. +#' @param client_config A list of client configurations used for OAuth. +#' @param require_auth Logical, whether authentication is required. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param key A secret key used to encrypt and decrypt tokens. +#' @param token_validity The duration for which the token should be cached. +#' +#' @return An HTTP response object or `NULL` if the callback parameters are +#' invalid. +#' @keywords internal +handle_oauth_client_callback <- function(req, client_config, require_auth, cookie, key, token_validity) { + query <- shiny::parseQueryString(req[["QUERY_STRING"]]) + if (is.null(query$code) || is.null(query$state)) { + return(NULL) + } + + # Verify retrieved state vs cookie state + state <- parse_cookies(req)[["oauth_state"]] + if (is.null(state)) { + cli::cli_alert_warning("No cookie with state was found.") + cli::cli_li("Does the callback url (redirect) match the app url?") + } + code <- oauth_flow_auth_code_parse(query, state) + pkce <- parse_cookies(req)[["oauth_pkce_verifier"]] + + # TODO: Refactor into function and validate exactly one match + client <- oauth_shiny_callback_resolve_client(client_config, query) + + # If openid, retrieve config for signature verification and resolving urls + if (!is.null(client$openid_issuer_url)) { + openid_config <- oauth_shiny_client_openid_config(client) + client <- oauth_shiny_client_openid_resolve_urls(client, openid_config) + } + + # Add PKCE verifier if applicable + if (!is.null(pkce)) { + client$token_params$code_verifier <- pkce + delete_cookie_header_pkce <- delete_cookie_header("oauth_pkce_verifier", cookie_options()) + } else { + delete_cookie_header_pkce <- NULL + } + + # Retrieve token + token <- oauth_client_get_token( + oauth_client( + id = client$id, + secret = client$secret, + token_url = client$token_url + ), + grant_type = "authorization_code", + code = code, + redirect_uri = client$redirect_uri, + !!!client$token_params + ) + + # If openid, verify signature and extract claims + if (!is.null(client$openid_issuer_url)) { + claims <- oauth_shiny_client_openid_verify_claims(client, openid_config, token) + } + + # If token should be post-processed (e.g. exchanged or similar), do it here + if (!is.null(client$postprocess_token)) { + token <- client$postprocess_token(token) + } + + # Set access token as cookie so it can be retrieved after redirection to app + set_access_token_cookie <- oauth_shiny_set_access_token( + client = client, + token = token, + key = key + ) + + # Set app token as cookie if the callback client is an auth provider for app + if (client$auth_provider && require_auth) { + # If the client is an OpenID provider, extract standard claims + if (!is.null(client$openid_issuer_url)) { + claims <- claims[client$openid_claims] + } else { + claims <- list() + } + + # If the client has a custom claims function, use that + if (!is.null(client$auth_set_custom_claim)) { + claims <- client$auth_set_custom_claim(client, token) + } + + # Generate a unique identifier for the user which will be verified on login + claims$identifier <- secret_make_key() + claims$provider <- client$name + + set_app_token_cookie <- oauth_shiny_set_app_token( + claims = claims, + cookie = cookie, + key = key, + token_validity = token_validity + ) + } else { + set_app_token_cookie <- NULL + } + + shiny::httpResponse( + status = 307L, + content_type = NULL, + content = "", + headers = rlang::list2( + Location = oauth_shiny_infer_app_url(req), + "Cache-Control" = "no-store", + !!!delete_cookie_header("oauth_state", cookie_options()), + !!!delete_cookie_header_pkce, + !!!set_app_token_cookie, + !!!set_access_token_cookie + ) + ) +} + +#' Handle OAuth Client Logout +#' +#' This function manages the logout process for OAuth clients. It deletes the +#' relevant cookies and redirects the user back to the app. +#' +#' @param req A `shiny` request object. +#' @param client_config A list of client configurations used for OAuth. +#' @param require_auth Logical, whether authentication is required. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param key A secret key used to encrypt and decrypt tokens. +#' +#' @return An HTTP response object or `NULL` if no client is matched. +#' @keywords internal +handle_oauth_client_logout <- function(req, client_config, require_auth, cookie, key) { + for (client in client_config) { + resp <- handle_oauth_client_logout_delete_cookies(req, client, require_auth, cookie, key) + if (!is.null(resp)) { + return(resp) + } + } +} + +#' Handle OAuth Client Logout - Delete Cookies +#' +#' This function deletes the cookies associated with a specific OAuth client +#' during the logout process. It then redirects the user back to the app. +#' +#' @param req A `shiny` request object. +#' @param client A single client configuration object. +#' @param require_auth Logical, whether authentication is required. +#' @param cookie The name of the cookie where the app's token is stored. +#' @param key A secret key used to encrypt and decrypt tokens. +#' +#' @return An HTTP response object or `NULL` if the request path does not match +#' the client's logout path. +#' @keywords internal +handle_oauth_client_logout_delete_cookies <- function(req, client, require_auth, cookie, key) { + if (sub("^/", "", req$PATH_INFO) != client$logout_path) { + return(NULL) + } + + has_auth <- !is.null(oauth_shiny_get_app_token_from_request(req, cookie, key)) + if(require_auth && !has_auth) { + return(NULL) + } + + cookies_app <- names(parse_cookies(req)) + cookies_match <- cookies_app[grepl(client$client_cookie_name, cookies_app)] + cookies_del <- unlist(lapply(cookies_match, delete_cookie_header, cookie_options())) + + shiny::httpResponse( + status = 307L, + headers = rlang::list2( + Location = oauth_shiny_infer_app_url(req), + "Cache-Control" = "no-store", + !!!cookies_del + ) + ) +} + +#' Resolve OAuth Client in Callback +#' +#' This function resolves which OAuth client configuration should be used during +#' the callback phase based on the query parameters returned by the OAuth +#' server. It ensures that the client matches either the state parameter or the +#' OpenID issuer URL. +#' +#' @param client_config A list of client configurations used for OAuth. +#' @param query A list of query parameters from the OAuth callback request. +#' +#' @keywords internal +#' @return The matching client configuration object. +oauth_shiny_callback_resolve_client <- function(client_config, query) { + for (client in client_config) { + # To-do: Maybe catch this earlier? Should we even allow a missing client id + if (is.null(client$id) || client$id == "") { + next + } + if (!is.null(client$openid_issuer_url) && !is.null(query$iss)) { + if (sub("/$", "", client$openid_issuer_url) == sub("/$", "", query$iss)) { + # If matches openid issuer + return(client) + } + } else if (grepl(client$id, query$state) && is.null(query$iss)) { + # If matches state + return(client) + } + } + cli::cli_abort("Callback from server matches neither issuer or state") +} diff --git a/R/oauth-shiny-openid.R b/R/oauth-shiny-openid.R new file mode 100644 index 00000000..a5a78386 --- /dev/null +++ b/R/oauth-shiny-openid.R @@ -0,0 +1,63 @@ +oauth_shiny_client_openid_config <- function(client) { + if (is.null(client$openid_issuer_url)) { + return(NULL) + } + url <- url_parse(client$openid_issuer_url) + url$path <- paste0(sub("/$", "", url$path), "/.well-known/openid-configuration") + + req <- request(url_build(url)) + req <- req_perform(req) + config <- resp_body_json(req) + config +} + +oauth_shiny_client_openid_resolve_urls <- function(client, openid_config = NULL) { + if (!is.null(client$auth_url) && !is.null(client$token_url)) { + return(client) + } + + if (is.null(openid_config)) { + openid_config <- oauth_shiny_client_openid_config(client) + } + + if (is.null(client$auth_url)) { + client$auth_url <- openid_config$authorization_endpoint + } + + if (is.null(client$token_url)) { + client$token_url <- openid_config$token_endpoint + } + + client +} + +oauth_shiny_client_openid_get_public_keys <- function(client, openid_config) { + if (is.null(openid_config)) { + openid_config <- oauth_shiny_client_openid_config(client) + } + req <- request(openid_config$jwks_uri) + req <- req_perform(req) + jwks <- resp_body_json(req, simplifyDataFame = FALSE) + key_names <- lapply(jwks$keys, function(x) x[["kid"]]) + keys <- lapply(jwks$keys, function(x) jose::jwk_read(jsonlite::toJSON(x))) + set_names(keys, key_names) +} + +oauth_shiny_client_openid_verify_claims <- function(client, openid_config, token) { + id_token <- token$id_token + kid <- jose::jwt_split(id_token)[["header"]][["kid"]] + keys <- oauth_shiny_client_openid_get_public_keys(client, openid_config) + key <- keys[[kid]] + + claims <- jose::jwt_decode_sig(id_token, key) + + if (claims$aud != client$id) { + cli::cli_abort("Audience mismatch") + } + + if (sub("/$", "", claims$iss) != sub("/$", "", client$openid_issuer_url)) { + cli::cli_abort("Issuer mismatch") + } + + claims +} diff --git a/R/oauth-shiny-token.R b/R/oauth-shiny-token.R new file mode 100644 index 00000000..e1ba1766 --- /dev/null +++ b/R/oauth-shiny-token.R @@ -0,0 +1,235 @@ +#' Set OAuth Token in Shiny App Cookie +#' +#' This function sets a JSON Web Token (JWT) and stores it in a cookie so the +#' user can be successfully identified and skip login when returning to the app. +#' The token is stored in a httponly, secure cookie and signed with HMAC. Claims +#' include `identifier`, `name`, `email`, `aud` and `sub`. +#' +#' @param claims A list containing the claims to be included in the JWT. +#' @param cookie A string representing the name of the cookie where the JWT will +#' be stored. +#' @param key A secret key used to sign the JWT. +#' @param token_validity Integer specifying the expiration time of the JWT in +#' seconds. +#' +#' @return None. The function sets the cookie in the HTTP response header. +oauth_shiny_set_app_token <- function(claims, cookie, key, token_validity) { + claims_subset <- subset(claims, !names(claims) %in% c("exp")) + jwt <- rlang::exec(jwt_claim, !!!claims_subset, exp = unix_time() + token_validity) + jwt_enc <- jwt_encode_hmac(jwt, charToRaw(key)) + + oauth_shiny_set_cookie_header( + name = cookie, + value = jwt_enc, + cookie_opts = cookie_options(max_age = token_validity) + ) +} + +#' Get OAuth Token from Shiny App Cookie +#' +#' This function retrieves and decodes the JWT stored in a specified cookie from +#' the incoming request. +#' +#' @param cookie A string representing the name of the cookie where the JWT is +#' stored. Default is `oauth_app_token`. +#' @param key The secret key used in the application. +#' @param session Defaults to `shiny::getDefaultReactiveDomain()` +#' +#' @return A decoded JWT if the cookie exists and is valid; otherwise, `NULL`. +#' @export +oauth_shiny_get_app_token <- function(cookie = "oauth_app_token", + key = oauth_shiny_app_passphrase(), + session = shiny::getDefaultReactiveDomain()) { + oauth_shiny_get_app_token_from_request(session$request, cookie, key) +} + +#' Get OAuth Token from Shiny App Cookie +#' +#' This function retrieves and decodes the JWT stored in a specified cookie from +#' the incoming request. +#' +#' @param req The incoming request object from which the cookie is to be +#' extracted. +#' @param cookie A string representing the name of the cookie where the JWT is +#' stored. Default is `oauth_app_token`. +#' @param key The secret key used in the application. +#' +#' @return A decoded JWT if the cookie exists and is valid; otherwise, `NULL`. +oauth_shiny_get_app_token_from_request <- function(req, cookie = "oauth_app_token", key) { + cookies <- parse_cookies(req) + cookie <- cookies[[cookie]] + + # If no cookie exists, return NULL + if (is.null(cookie) || cookie == "") { + return(NULL) + } + + # If token decoding fails, return NULL + decoded_token <- tryCatch( + { + jose::jwt_decode_hmac(cookie, charToRaw(key)) + }, + error = function(e) { + cli::cli_alert_warning("Failed to decode app token from cookie") + cli::cli_li(e$message) + return(NULL) + } + ) + + # If identifier in claims is not included, return NULL + if (is.null(decoded_token$identifier) || decoded_token$identifier == "") { + return(NULL) + } + + decoded_token +} + +#' Set OAuth Client Access Token in Shiny App Cookie +#' +#' This function stores an OAuth access token in a cookie, encrypting it before +#' storage. The token is an `oauth_token` object containing access_token and +#' related information. +#' +#' @param client An `oauth_shiny_client` representing the client configuration +#' @param token A list representing the OAuth token to be stored. +#' @param key A secret key used for encrypting the token. +#' +#' @return None. The function sets the encrypted token in the HTTP response +#' header as a cookie. +oauth_shiny_set_access_token <- function(client, token, key) { + if (is.null(client$access_token_validity) || client$access_token_validity > 0) { + expires_at <- as.integer(token[["expires_at"]] - as.numeric(Sys.time())) + max_age <- min(expires_at, client$access_token_validity) + + # Since shiny can't read expiry time of a cookie, add it to the token + token[["cookie_expires_at"]] <- unix_time() + max_age + + oauth_shiny_set_cookie_header( + name = client$client_cookie_name, + value = oauth_shiny_encrypt_client_token(client, token, key, max_age), + cookie_opts = cookie_options(max_age = max_age) + ) + } +} + +#' Get OAuth Client Access Token from Shiny App Cookie +#' +#' This function retrieves and decrypts an OAuth token stored in a cookie. +#' It automatically handles chunked cookies if the encrypted token exceeds +#' maximum cookie size (e.g. `oauth_app_google_token_1` and +#' `oauth_app_google_token2`) +#' +#' @param client A list representing the client configuration, +#' including the client name and secret. +#' @param key A secret key used to decrypt the token. +#' @param session The current Shiny session object, used to access request +#' details. Default is `shiny::getDefaultReactiveDomain()`. +#' +#' @return A decrypted `oauth_token` if the cookie exists and is valid +#' @export +oauth_shiny_get_access_token <- function(client, + key = oauth_shiny_app_passphrase(), + session = shiny::getDefaultReactiveDomain()) { + cookie_name <- paste0("oauth_app_", client$name, "_token") + cookies <- parse_cookies(session$request) + # If client contains chunked cookies, paste them together + cookie <- paste(cookies[grepl(cookie_name, names(cookies))], collapse = "") + + if (cookie == "") { + return(NULL) + } + + key <- paste0(key, client$secret) + + token <- tryCatch( + oauth_shiny_decrypt_client_token(cookie, key), + error = function(e) { + cli::cli_alert_warning("Failed attempt to retrieve client cookie {.field {cookie_name}}") + cli::cli_ul() + cli::cli_li(e$message) + return(NULL) + } + ) + token +} + +#' Decrypt OAuth Access Token +#' +#' This function decrypts an OAuth access token stored in a cookie. +#' +#' @param cookie A string representing the encrypted cookie value. +#' @param key A secret key used to decrypt the cookie. +#' +#' @return A decrypted OAuth token object. +oauth_shiny_decrypt_client_token <- function(cookie, key) { + bytes <- sodium::hex2bin(cookie) + + if (length(bytes) <= 32 + 24) { + stop("Cookie payload was too short") + } + + salt <- bytes[1:32] + nonce <- bytes[32 + (1:24)] + rest <- utils::tail(bytes, -(32 + 24)) + + key_scrypt <- sodium::scrypt(charToRaw(key), salt = salt, size = 32) + + # Decrypt cookie + cleartext <- sodium::data_decrypt(rest, key = key_scrypt, nonce = nonce) + + cleartext <- rawToChar(cleartext) + Encoding(cleartext) <- "UTF-8" + + # Decode and verify signature and validity + decoded_token <- jose::jwt_decode_hmac(cleartext, charToRaw(key)) + + token <- jsonlite::fromJSON(decoded_token[["token"]]) + + reserved_args <- c("token_type", "access_token", "refresh_token", "expires_at") + meta <- subset(token, !names(token) %in% reserved_args) + + expires_at <- token[["expires_at"]] + + if (!is.null(expires_at)) { + expires_in <- floor(expires_at - as.numeric(Sys.time())) + } else { + expires_in <- NULL + } + + oauth_token( + token_type = token$token_type, + access_token = token$access_token, + expires_in = expires_in, + refresh_token = token$refresh_token, + !!!meta + ) +} + +#' Encrypt OAuth Client Access Token +#' +#' This function encrypts an OAuth access token for storage in a cookie. +#' +#' @param client A list representing the client configuration, including the +#' client secret. +#' @param token A list representing the OAuth token to be encrypted. +#' @param key A secret key used to encrypt the token. +#' @param max_age Integer specifying the expiration time of the token in +#' seconds. +#' +#' @return A string representing the encrypted token in hexadecimal format. +oauth_shiny_encrypt_client_token <- function(client, token, key, max_age) { + jwt <- jwt_claim( + exp = unix_time() + max_age, + token = jsonlite::toJSON(unclass(token), auto_unbox = TRUE) + ) + + key <- paste0(key, client$secret) + cred <- jwt_encode_hmac(jwt, charToRaw(key)) + + salt <- sodium::random(32) + nonce <- sodium::random(24) + key_scrypt <- sodium::scrypt(charToRaw(key), salt = salt, size = 32) + ciphertext <- sodium::data_encrypt(charToRaw(cred), key = key_scrypt, nonce = nonce) + + sodium::bin2hex(c(salt, nonce, ciphertext)) +} diff --git a/R/oauth-shiny-ui.R b/R/oauth-shiny-ui.R new file mode 100644 index 00000000..75ce91bd --- /dev/null +++ b/R/oauth-shiny-ui.R @@ -0,0 +1,290 @@ +#' Create an OAuth Shiny UI Login Page +#' +#' This function creates the Shiny UI for the OAuth login page, allowing users +#' to log in with their preferred OAuth provider. The UI can be customized for +#' dark mode. +#' +#' @param oauth_shiny_client_config A list of client configurations for OAuth +#' providers. +#' @param dark_mode A logical value indicating whether to use dark mode. +#' Defaults to `FALSE`. +#' +#' @return A Shiny UI object representing the login page. +#' @export +oauth_shiny_ui_login <- function(oauth_shiny_client_config, dark_mode = FALSE) { + dep <- htmltools::htmlDependency( + name = "oauth-ui", + version = "1.0", + src = "shiny", + package = "httr2", + all_files = TRUE, + stylesheet = "style.css" + ) + + ui <- shiny::fluidPage( + if (dark_mode) shiny::tags$style("body {background-color: black;}"), + shiny::fluidRow( + shiny::div( + class = "full-page-container center-content", + shiny::wellPanel( + class = paste("login-panel", ifelse(dark_mode, "dark-panel", "")), + shiny::h2("Welcome"), + shiny::p("Login with your preferred provider"), + lapply(oauth_shiny_client_config, oauth_shiny_ui_login_button, dark_mode = dark_mode) + ) + ) + ) + ) + + shiny::tagList(dep, ui) +} + +oauth_shiny_ui_login_button <- function(client, dark_mode = FALSE) { + if (client$auth_provider) { + ui <- if (dark_mode) client$login_button_dark else client$login_button + } else { + ui <- NULL + } +} + +#' Create an OAuth Shiny UI Logout Page +#' +#' This function creates the Shiny UI for the OAuth logout page, displaying a +#' message that the user has successfully logged out. +#' +#' @param oauth_shiny_client_config A list of client configurations for OAuth +#' providers. +#' @param dark_mode A logical value indicating whether to use dark mode. +#' Defaults to `FALSE`. +#' +#' @return A Shiny UI object representing the logout page. +#' @export +oauth_shiny_ui_logout <- function(oauth_shiny_client_config, dark_mode = FALSE) { + dep <- htmltools::htmlDependency( + name = "oauth-ui", + version = "1.0", + src = "shiny", + package = "httr2", + all_files = TRUE, + stylesheet = "style.css" + ) + + ui <- shiny::fluidPage( + if (dark_mode) shiny::tags$style("body {background-color: black;}"), + shiny::div( + class = ifelse(dark_mode, "dark-mode", ""), + shiny::fluidRow( + shiny::div( + class = "full-page-container center-content", + shiny::wellPanel( + class = paste("login-panel", ifelse(dark_mode, "dark-panel", "")), + shiny::h2("Logged out"), + shiny::p("You are successfully logged out"), + lapply(oauth_shiny_client_config, oauth_shiny_ui_login_button, dark_mode = dark_mode) + ) + ) + ) + ) + ) + shiny::tagList(dep, ui) +} + +#' Create a Custom OAuth Shiny UI Login Button +#' +#' This function creates a custom login button for an OAuth provider, allowing +#' customization of the button's appearance. +#' +#' @param path The URL path for the OAuth login. +#' @param title The title to display on the button. +#' @param logo The file path or SVG code for the logo to display on the button. +#' @param theme The theme of the button, either "light" or "dark". Defaults to +#' "light". +#' +#' @return A Shiny UI object representing the login button. +#' @export +oauth_shiny_ui_button <- function(path, + title, + logo = NULL, + theme = c("light", "dark")) { + theme <- match.arg(theme) + + # Set button class based on theme + button_class <- if (theme == "light") { + "gsi-material-button" + } else { + "gsi-material-button dark-mode" + } + + # Determine image source + if (!is.null(logo)) { + # Attempt to encode the logo (as file or SVG code) + src <- base64_img_encode(logo) + } else { + src <- NULL + } + + # Create image element if source is available + img <- if (!is.null(src)) { + shiny::div( + class = "gsi-material-button-icon", + shiny::img( + src = src, + class = "gsi-material-button-icon-img", + height = 20 + ) + ) + } else { + shiny::div(class = "gsi-material-button-icon") + } + + # Return the Shiny UI button component + shiny::tagList( + htmltools::htmlDependency( + name = "oauth-ui", + version = "1.0", + src = "shiny", + package = "httr2", + all_files = TRUE, + stylesheet = "style.css" + ), + shiny::div( + class = "gsi-material-button-container", + shiny::a( + href = path, + shiny::tags$button( + class = button_class, + shiny::div(class = "gsi-material-button-state"), + shiny::div( + class = "gsi-material-button-content-wrapper", + img, + shiny::span(class = "gsi-material-button-contents", title), + shiny::span(style = "display: none;", title) + ) + ) + ) + ) + ) +} + + +oauth_shiny_ui_button_apple <- function(path = "login/apple", title = "Sign in with Apple") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/apple.svg", package = "httr2"), + theme = "light" + ) +} + +oauth_shiny_ui_button_apple_dark <- function(path = "login/apple", title = "Sign in with Apple") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/apple-dark.svg", package = "httr2"), + theme = "dark" + ) +} + +oauth_shiny_ui_button_google <- function(path = "login/google", title = "Sign in with Google") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/google.svg", package = "httr2"), + theme = "light" + ) +} + +oauth_shiny_ui_button_google_dark <- function(path = "login/google", title = "Sign in with Google") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/google.svg", package = "httr2"), + theme = "dark" + ) +} + +oauth_shiny_ui_button_facebook <- function(path = "login/facebook", title = "Sign in with Facebook") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/facebook.svg", package = "httr2"), + theme = "light" + ) +} + +oauth_shiny_ui_button_facebook_dark <- function(path = "login/facebook", title = "Sign in with Facebook") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/facebook.svg", package = "httr2"), + theme = "dark" + ) +} + +oauth_shiny_ui_button_github <- function(path = "login/github", title = "Sign in with Github") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/github.svg", package = "httr2"), + theme = "light" + ) +} + +oauth_shiny_ui_button_github_dark <- function(path = "login/github", title = "Sign in with Github") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/github-dark.svg", package = "httr2"), + theme = "dark" + ) +} + +oauth_shiny_ui_button_microsoft <- function(path = "login/microsoft", title = "Sign in with Microsoft") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/microsoft.svg", package = "httr2"), + theme = "light" + ) +} + +oauth_shiny_ui_button_microsoft_dark <- function(path = "login/microsoft", title = "Sign in with Microsoft") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/microsoft.svg", package = "httr2"), + theme = "dark" + ) +} + +oauth_shiny_ui_button_spotify <- function(path = "login/spotify", title = "Sign in with Spotify") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/spotify.svg", package = "httr2"), + theme = "light" + ) +} + +oauth_shiny_ui_button_spotify_dark <- function(path = "login/spotify", title = "Sign in with Spotify") { + oauth_shiny_ui_button( + path = path, + title = title, + logo = system.file("shiny/spotify.svg", package = "httr2"), + theme = "dark" + ) +} + +render_static_html_document <- function(ui) { + lang <- attr(ui, "lang", exact = TRUE) %||% "en" + if (!(inherits(ui, "shiny.tag") && ui$name == "body")) { + ui <- shiny::tags$body(ui) + } + doc <- htmltools::htmlTemplate( + system.file("shiny", "default.html", package = "httr2"), + lang = lang, + body = ui, + document_ = TRUE + ) + htmltools::renderDocument(doc, processDep = shiny::createWebDependency) +} diff --git a/R/utils.R b/R/utils.R index ac707da4..b8412ad1 100644 --- a/R/utils.R +++ b/R/utils.R @@ -111,6 +111,43 @@ base64_url_rand <- function(bytes = 32) { base64_url_encode(openssl::rand_bytes(bytes)) } +base64_img_encode <- function(input) { + # Check if the input is a file path and exists + is_file <- file.exists(input) + + if (!is_file && !grepl(" diff --git a/inst/shiny/apple.svg b/inst/shiny/apple.svg new file mode 100644 index 00000000..46ee5f96 --- /dev/null +++ b/inst/shiny/apple.svg @@ -0,0 +1,5 @@ + diff --git a/inst/shiny/default.html b/inst/shiny/default.html new file mode 100644 index 00000000..866d2b27 --- /dev/null +++ b/inst/shiny/default.html @@ -0,0 +1,7 @@ + + +
+{{ headContent() }} + +{{ body }} + diff --git a/inst/shiny/facebook.svg b/inst/shiny/facebook.svg new file mode 100644 index 00000000..a909c707 --- /dev/null +++ b/inst/shiny/facebook.svg @@ -0,0 +1,15 @@ + diff --git a/inst/shiny/github-dark.svg b/inst/shiny/github-dark.svg new file mode 100644 index 00000000..d4838d36 --- /dev/null +++ b/inst/shiny/github-dark.svg @@ -0,0 +1,3 @@ + diff --git a/inst/shiny/github.svg b/inst/shiny/github.svg new file mode 100644 index 00000000..1c233af3 --- /dev/null +++ b/inst/shiny/github.svg @@ -0,0 +1,3 @@ + diff --git a/inst/shiny/google.svg b/inst/shiny/google.svg new file mode 100644 index 00000000..d733a534 --- /dev/null +++ b/inst/shiny/google.svg @@ -0,0 +1,7 @@ + diff --git a/inst/shiny/microsoft.svg b/inst/shiny/microsoft.svg new file mode 100644 index 00000000..d1e0ecc9 --- /dev/null +++ b/inst/shiny/microsoft.svg @@ -0,0 +1,8 @@ + diff --git a/inst/shiny/spotify.svg b/inst/shiny/spotify.svg new file mode 100644 index 00000000..4f2e8032 --- /dev/null +++ b/inst/shiny/spotify.svg @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/inst/shiny/style.css b/inst/shiny/style.css new file mode 100644 index 00000000..de78b954 --- /dev/null +++ b/inst/shiny/style.css @@ -0,0 +1,257 @@ +/** Welcome UI **/ +.full-page-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} + +.center-content { + text-align: center; +} + +.login-panel { + max-width: 600px; + margin: 0 auto !important; /* Ensure it is centered within the parent div */ + padding: 20px; /* Adding padding for better spacing */ + background-color: #f8f9fa; /* Light background for light mode */ + color: #333; /* Dark text for light mode */ +} + +/* Dark mode styles */ +.dark-mode { + background-color: #121212 !important; + color: #e0e0e0; +} + +.dark-panel { + background-color: #1e1e1e !important; /* Dark background for dark mode */ + color: #e0e0e0; /* Light text for dark mode */ + border-color: #8e918f !important; /* Darker border for dark mode */ +} + +.dark-mode .center-content { + color: #e0e0e0; +} + +/** Button frame styles **/ + +.gsi-material-button-container:not(:last-child) { + padding-bottom: 5px; +} + +.gsi-material-button { + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + -webkit-appearance: none; + background-color: WHITE; + background-image: none; + border: 1px solid #747775; + -webkit-border-radius: 4px; + border-radius: 4px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: #1f1f1f; + cursor: pointer; + font-family: 'Roboto', arial, sans-serif; + font-size: 14px; + height: 40px; + letter-spacing: 0.25px; + outline: none; + overflow: hidden; + padding: 0 12px; + position: relative; + text-align: left; + -webkit-transition: background-color .218s, border-color .218s, box-shadow .218s; + transition: background-color .218s, border-color .218s, box-shadow .218s; + vertical-align: middle; + white-space: nowrap; + width: auto; + max-width: 400px; + min-width: 210px; +} + +.gsi-material-button.icon-button { + padding: 0; + width: 40px; +} + +.gsi-material-button.circle, +.gsi-material-button.pill { + -webkit-border-radius: 20px; + border-radius: 20px; +} + +.gsi-material-button.dark-mode { + background-color: #131314; + border-color: #8e918f; + color: #e3e3e3; +} + +.gsi-material-button.neutral { + background-color: #f2f2f2; + border: none; +} + + +/** Google Icon Styles **/ + +.gsi-material-button .gsi-material-button-icon { + height: 20px; + margin-right: 12px; + min-width: 20px; + width: 20px; +} + +.gsi-material-button.icon-button .gsi-material-button-icon { + margin: 0; + padding: 9px; +} + +.gsi-material-button.icon-button.neutral .gsi-material-button-icon { + margin: 0; + padding: 10px; +} + +/** Content Styles **/ + +.gsi-material-button .gsi-material-button-content-wrapper { + -webkit-align-items: center; + align-items: center; + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + -webkit-flex-direction: row; + flex-direction: row; + -webkit-flex-wrap: nowrap; + flex-wrap: nowrap; + height: 100%; + justify-content: space-between; + position: relative; + width: 100%; +} + +.gsi-material-button .center-logo { + justify-content: center; +} + +.gsi-material-button .gsi-material-button-contents { + -webkit-flex-grow: 1; + flex-grow: 1; + font-family: 'Roboto', arial, sans-serif; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} + +.gsi-material-button .center-logo .gsi-material-button-contents { + -webkit-flex-grow: 0; + flex-grow: 0; +} + + +/** State Styles **/ + +.gsi-material-button .gsi-material-button-state { + -webkit-transition: opacity .218s; + transition: opacity .218s; + bottom: 0; + left: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; +} + +/** State: Disabled **/ + +.gsi-material-button:disabled { + cursor: default; +} + +.gsi-material-button.light-mode:disabled { + background-color: #ffffff61; + border-color: #1f1f1f1f; +} + +.gsi-material-button.dark-mode:disabled { + background-color: #13131461; + border-color: #8e918f1f; +} + +.gsi-material-button.neutral:disabled { + background-color: #ffffff61; +} + +.gsi-material-button.dark-mode:disabled .gsi-material-button-state { + background-color: #e3e3e31f; +} + +.gsi-material-button.neutral:disabled .gsi-material-button-state { + background-color: #1f1f1f1f; +} + +.gsi-material-button:disabled .gsi-material-button-contents { + opacity: 38%; +} + +.gsi-material-button:disabled .gsi-material-button-icon { + opacity: 38%; +} + +/** State: Active/Focused **/ + +.gsi-material-button.light-mode:not(:disabled):active .gsi-material-button-state, +.gsi-material-button.light-mode:not(:disabled):focus .gsi-material-button-state { + background-color: #303030; + opacity: 12%; +} + +.gsi-material-button.dark-mode:not(:disabled):active .gsi-material-button-state, +.gsi-material-button.dark-mode:not(:disabled):focus .gsi-material-button-state { + background-color: white; + opacity: 12%; +} + +.gsi-material-button.neutral:not(:disabled):active .gsi-material-button-state, +.gsi-material-button.neutral:not(:disabled):focus .gsi-material-button-state { + background-color: #001d35; + opacity: 12%; +} + +/** State: Hovered **/ + +.gsi-material-button:not(:disabled):hover { + -webkit-box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), + 0 1px 3px 1px rgba(60, 64, 67, .15); + box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), + 0px 1px 3px 1px rgba(60, 64, 67, .15); +} + +.gsi-material-button.light-mode:not(:disabled):hover .gsi-material-button-state { + background-color: #303030; + opacity: 8%; +} + +.gsi-material-button.dark-mode:not(:disabled):hover .gsi-material-button-state { + background-color: white; + opacity: 8%; +} + +.gsi-material-button.neutral:not(:disabled):hover .gsi-material-button-state { + background-color: #001d35; + opacity: 8%; +} + +/** Provider icons **/ +.gsi-material-button-icon-img { + vertical-align: text-bottom !important; + display: block; + max-height: 20px; + margin-left: auto; + margin-right: auto; +} diff --git a/man/handle_oauth_app_logged_in.Rd b/man/handle_oauth_app_logged_in.Rd new file mode 100644 index 00000000..8b9b9603 --- /dev/null +++ b/man/handle_oauth_app_logged_in.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_app_logged_in} +\alias{handle_oauth_app_logged_in} +\title{Handle OAuth for Logged-in App Users} +\usage{ +handle_oauth_app_logged_in( + req, + client_config, + require_auth, + cookie, + key, + httpHandler +) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client_config}{A list of client configurations used for OAuth.} + +\item{require_auth}{Logical, whether authentication is required.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{key}{A secret key used to encrypt and decrypt tokens.} + +\item{httpHandler}{A function to handle HTTP requests after authentication.} +} +\value{ +An HTTP response object or \code{NULL} if authentication fails. +} +\description{ +This function processes OAuth requests for logged-in users of the app. It +checks for an existing OAuth token, and if none is found and authentication +is required, it returns \code{NULL}. Otherwise, it invokes the provided HTTP +handler to continue processing the request. +} +\keyword{internal} diff --git a/man/handle_oauth_app_login.Rd b/man/handle_oauth_app_login.Rd new file mode 100644 index 00000000..bc6a0a62 --- /dev/null +++ b/man/handle_oauth_app_login.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_app_login} +\alias{handle_oauth_app_login} +\title{Handle OAuth Login for App} +\usage{ +handle_oauth_app_login(req, client_config, login_ui) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client_config}{A list of client configurations used for OAuth.} + +\item{login_ui}{A UI function or object that provides a custom login page.} +} +\value{ +An HTTP response object or \code{NULL} if the request path is not the +root. +} +\description{ +This function handles the OAuth login process for the app. It checks if the +request path is the root ("/"), and if so, either redirects to the primary +authentication provider or displays a custom login UI. The custom UI can +include a message and login options. +} +\keyword{internal} diff --git a/man/handle_oauth_app_logout.Rd b/man/handle_oauth_app_logout.Rd new file mode 100644 index 00000000..5ee5d20a --- /dev/null +++ b/man/handle_oauth_app_logout.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_app_logout} +\alias{handle_oauth_app_logout} +\title{Handle OAuth Logout for App} +\usage{ +handle_oauth_app_logout(req, client_config, logout_path, cookie, logout_ui) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client_config}{A list of client configurations used for OAuth.} + +\item{logout_path}{The path that triggers the app's logout process.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{logout_ui}{A UI function or object that provides a custom logout page.} +} +\value{ +An HTTP response object or \code{NULL} if the request path does not match +the logout path. +} +\description{ +This function handles the logout process for the app. It deletes the relevant +cookies from the user's browser and returns an HTTP response that either +displays a logout UI or redirects the user directly. +} +\keyword{internal} diff --git a/man/handle_oauth_client_callback.Rd b/man/handle_oauth_client_callback.Rd new file mode 100644 index 00000000..8d28bef4 --- /dev/null +++ b/man/handle_oauth_client_callback.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_client_callback} +\alias{handle_oauth_client_callback} +\title{Handle OAuth Client Callback} +\usage{ +handle_oauth_client_callback( + req, + client_config, + require_auth, + cookie, + key, + token_validity +) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client_config}{A list of client configurations used for OAuth.} + +\item{require_auth}{Logical, whether authentication is required.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{key}{A secret key used to encrypt and decrypt tokens.} + +\item{token_validity}{The duration for which the token should be cached.} +} +\value{ +An HTTP response object or \code{NULL} if the callback parameters are +invalid. +} +\description{ +This function processes the OAuth callback after the user has authorized the +app. It exchanges the authorization code for an access token, handles PKCE +verification, and sets cookies for both the app and the client access token. +} +\keyword{internal} diff --git a/man/handle_oauth_client_login.Rd b/man/handle_oauth_client_login.Rd new file mode 100644 index 00000000..79cde3de --- /dev/null +++ b/man/handle_oauth_client_login.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_client_login} +\alias{handle_oauth_client_login} +\title{Handle OAuth Client Login} +\usage{ +handle_oauth_client_login(req, client_config, require_auth, cookie, key) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client_config}{A list of client configurations used for OAuth.} + +\item{require_auth}{Logical, whether authentication is required.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{key}{A secret key used to encrypt and decrypt tokens.} +} +\value{ +An HTTP response object or \code{NULL} if no client is matched. +} +\description{ +This function initiates the OAuth login process for clients configured with +the app. It loops through the client configurations and redirects the user to +the appropriate OAuth authorization URL. +} +\keyword{internal} diff --git a/man/handle_oauth_client_login_redirect.Rd b/man/handle_oauth_client_login_redirect.Rd new file mode 100644 index 00000000..04812358 --- /dev/null +++ b/man/handle_oauth_client_login_redirect.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_client_login_redirect} +\alias{handle_oauth_client_login_redirect} +\title{Handle OAuth Client Login Redirect} +\usage{ +handle_oauth_client_login_redirect(req, client, require_auth, cookie, key) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client}{A single client configuration object.} + +\item{require_auth}{Logical, whether authentication is required.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{key}{A secret key used to encrypt and decrypt tokens.} +} +\value{ +An HTTP response object or \code{NULL} if the request path does not match +the client's login path. +} +\description{ +This function is invoked when the user is redirected to a client login +endpoint set by \code{login_path}. It handles redirection to the OAuth +authorization URL for a specific client. It manages state and PKCE (if +enabled) by setting cookies when redirecting the user to the OAuth endpoint. +} +\keyword{internal} diff --git a/man/handle_oauth_client_logout.Rd b/man/handle_oauth_client_logout.Rd new file mode 100644 index 00000000..22fd33e5 --- /dev/null +++ b/man/handle_oauth_client_logout.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_client_logout} +\alias{handle_oauth_client_logout} +\title{Handle OAuth Client Logout} +\usage{ +handle_oauth_client_logout(req, client_config, require_auth, cookie, key) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client_config}{A list of client configurations used for OAuth.} + +\item{require_auth}{Logical, whether authentication is required.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{key}{A secret key used to encrypt and decrypt tokens.} +} +\value{ +An HTTP response object or \code{NULL} if no client is matched. +} +\description{ +This function manages the logout process for OAuth clients. It deletes the +relevant cookies and redirects the user back to the app. +} +\keyword{internal} diff --git a/man/handle_oauth_client_logout_delete_cookies.Rd b/man/handle_oauth_client_logout_delete_cookies.Rd new file mode 100644 index 00000000..05efecfc --- /dev/null +++ b/man/handle_oauth_client_logout_delete_cookies.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{handle_oauth_client_logout_delete_cookies} +\alias{handle_oauth_client_logout_delete_cookies} +\title{Handle OAuth Client Logout - Delete Cookies} +\usage{ +handle_oauth_client_logout_delete_cookies( + req, + client, + require_auth, + cookie, + key +) +} +\arguments{ +\item{req}{A \code{shiny} request object.} + +\item{client}{A single client configuration object.} + +\item{require_auth}{Logical, whether authentication is required.} + +\item{cookie}{The name of the cookie where the app's token is stored.} + +\item{key}{A secret key used to encrypt and decrypt tokens.} +} +\value{ +An HTTP response object or \code{NULL} if the request path does not match +the client's logout path. +} +\description{ +This function deletes the cookies associated with a specific OAuth client +during the logout process. It then redirects the user back to the app. +} +\keyword{internal} diff --git a/man/oauth_shiny_app.Rd b/man/oauth_shiny_app.Rd new file mode 100644 index 00000000..83712405 --- /dev/null +++ b/man/oauth_shiny_app.Rd @@ -0,0 +1,110 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-app.R +\name{oauth_shiny_app} +\alias{oauth_shiny_app} +\title{Integrate OAuth into a Shiny Application} +\usage{ +oauth_shiny_app( + app, + client_config, + require_auth = TRUE, + key = oauth_shiny_app_passphrase(), + dark_mode = FALSE, + login_ui = oauth_shiny_ui_login(client_config, dark_mode), + logout_ui = oauth_shiny_ui_logout(client_config, dark_mode), + logout_path = "logout", + logout_on_token_expiry = FALSE, + cookie_name = "oauth_app_token", + token_validity = 3600 +) +} +\arguments{ +\item{app}{A Shiny app object, typically created using \code{\link[shiny:shinyApp]{shiny::shinyApp()}}. +For improved readability, consider using the pipe operator, e.g., +\code{shinyApp() |> oauth_shiny_app(...)}.} + +\item{client_config}{An \code{oauth_shiny_config} object that specifies the OAuth +clients to be used. This object should include configurations for one or +more OAuth providers, created with \verb{oauth_shiny_client_*()} functions.} + +\item{require_auth}{Logical; determines whether user authentication is +mandatory before accessing the app. Set to \code{TRUE} to enforce login, which +will redirect unauthenticated users to the OAuth login UI. Set to \code{FALSE} +for a public app where login is optional but token retrieval is still +supported. Defaults to \code{TRUE}.} + +\item{key}{The encryption key used to secure cookies containing +authentication information. This key should be a long, randomly generated +string. By default, it is retrieved from the environment variable +\code{HTTR2_OAUTH_PASSPHRASE}. You can generate a suitable key using +\code{httr2::secret_make_key()} or a similar method.} + +\item{dark_mode}{Logical; specifies whether the login and logout user +interfaces should use a dark mode theme. If \code{TRUE}, the interfaces will +adopt a dark color scheme. Defaults to \code{FALSE}.} + +\item{login_ui}{The user interface displayed to users for login when +\code{require_auth = TRUE}. By default, this is automatically generated based on +the OAuth clients specified in \code{client_config}. You can provide a custom UI +if desired.} + +\item{logout_ui}{The user interface shown to users for logout. By default, +this UI is automatically generated based on the OAuth clients in +\code{client_config}. You can provide a custom UI to override the default +behavior.} + +\item{logout_path}{The URL path used to handle user logout requests. Users +will be redirected to this path to log out of the application. Defaults to +\code{'logout'}. If you wish to customize the logout path, specify it here.} + +\item{logout_on_token_expiry}{Logical; determines if users should be +automatically logged out when the app token expires. If \code{TRUE}, the user +session will end when the token expires. If \code{FALSE}, the session remains +active until the user manually logs out or refreshes the browser. Defaults +to \code{FALSE}.} + +\item{cookie_name}{The name of the cookie used to store authentication +information. This cookie holds the app token containing user claims. +Defaults to \code{'oauth_app_token'}. You can specify a different name if +needed.} + +\item{token_validity}{Numeric; the duration in seconds for which the user's +session remains valid. This controls how long the JWT or access token is +valid before it expires. Defaults to \code{3600} seconds (1 hour).} +} +\description{ +This function integrates OAuth-based authentication into Shiny +applications, managing the full OAuth authorization code flow including +token acquisition, storage, and session management. It supports two main +scenarios: +\enumerate{ +\item \strong{Enforcing User Login}: Users must authenticate through an OAuth +provider before accessing the Shiny app. The login interface can be +automatically generated based on the \code{client_config} or provided via the +\code{login_ui} parameter. Alternatively, you can bypass the login UI and +redirect users directly to the OAuth client by setting \code{login_ui} to \code{NULL} +and configuring a primary authentication provider in the \code{client_config}. +This setup is useful in enterprise environments where seamless integration +with single sign-on (SSO) solutions is desired. +\item \strong{Retrieving Tokens on Behalf of Users}: This functionality allows for +obtaining OAuth tokens from users, which can be used for accessing external +APIs. This can be applied whether or not user login is enforced. When +\code{require_auth = TRUE}, users must log in, and the tokens can be used in the +context of their authenticated session. When \code{require_auth = FALSE}, tokens +are retrieved from users in a public app setting where login is optional or +not enforced. In both scenarios, tokens are stored securely in encrypted +cookies and can be retrieved using \code{oauth_shiny_get_access_token()}. +} + +The function manages OAuth by setting two types of cookies: +\itemize{ +\item \strong{App Cookie}: Contains a JSON Web Token (JWT) that holds user claims +such as \code{name}, \code{email}, \code{sub}, and \code{aud}. This cookie is used to maintain +the user's session in the Shiny app. It can be retrieved in a shiny app +using \code{oauth_shiny_get_app_token()} +\item \strong{Access Token Cookie}: If the \code{access_token_validity} for a client is +greater than 0, an additional cookie is created to store the OAuth access +token. This cookie is encrypted and can be retrieved using +\code{oauth_shiny_get_access_token()}. +} +} diff --git a/man/oauth_shiny_app_example.Rd b/man/oauth_shiny_app_example.Rd new file mode 100644 index 00000000..0fb65614 --- /dev/null +++ b/man/oauth_shiny_app_example.Rd @@ -0,0 +1,77 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-example.R +\name{oauth_shiny_app_example} +\alias{oauth_shiny_app_example} +\title{Example Shiny App Using OAuth} +\usage{ +oauth_shiny_app_example( + client_config, + require_auth = TRUE, + key = oauth_shiny_app_passphrase(), + dark_mode = FALSE, + login_ui = oauth_shiny_ui_login(client_config, dark_mode), + logout_ui = oauth_shiny_ui_logout(client_config, dark_mode), + logout_path = "logout", + logout_on_token_expiry = FALSE, + cookie_name = "oauth_app_token", + token_validity = 3600, + redact = TRUE +) +} +\arguments{ +\item{client_config}{A \code{oauth_shiny_config} object containing a list of OAuth +client configurations (\code{oauth_shiny_client} objects).} + +\item{require_auth}{Logical; should login be enforced? Defaults to \code{FALSE}.} + +\item{key}{The encryption key for securing cookies. Defaults to the +environment variable \code{HTTR2_OAUTH_PASSPHRASE}. It should be a long, +randomly generated string, which can be created using +\code{httr2::secret_make_key()} or a similar method.} + +\item{dark_mode}{Logical; should the login and logout UI use dark mode? +Defaults to \code{FALSE}..} + +\item{login_ui}{The login UI to present to users when \code{enforce_login = TRUE}. +Defaults to an automatically generated UI based on the clients listed in +\code{client_config}.} + +\item{logout_ui}{The logout UI to present to users. Defaults to an +automatically generated UI based on the providers listed in +\code{client_config}.} + +\item{logout_path}{The URL path used to log users out of the app. Defaults to +\code{'logout'}.} + +\item{logout_on_token_expiry}{Logical; should the user be automatically +logged out of the app when the token expires? If \code{FALSE}, the session +remains active until the user refreshes the browser or manually logs out. +Defaults to \code{FALSE}.} + +\item{cookie_name}{The name of the cookie used for authentication. Defaults +to \code{'oauth_app_token'}.} + +\item{token_validity}{Validity of the app token in seconds. Defaults to +1 hour (3600s) after which the token and cookie expires.} + +\item{redact}{Logical, whether to redact tokens in UI. Defaults to \code{TRUE}.} +} +\value{ +A configured Shiny app object that allows users to authenticate using +the specified OAuth providers. +} +\description{ +This function sets up a Shiny application that uses multiple OAuth providers +for authentication. The application can be configured to allow users to sign +in using various OAuth providers like Google, Microsoft, GitHub, and others. +} +\examples{ +\dontrun{ +config <- oauth_shiny_client_config( + oauth_shiny_client_github(), + oauth_shiny_client_google() +) + +oauth_shiny_app_example(config, dark_mode = TRUE) +} +} diff --git a/man/oauth_shiny_app_passphrase.Rd b/man/oauth_shiny_app_passphrase.Rd new file mode 100644 index 00000000..9e3306da --- /dev/null +++ b/man/oauth_shiny_app_passphrase.Rd @@ -0,0 +1,11 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-app.R +\name{oauth_shiny_app_passphrase} +\alias{oauth_shiny_app_passphrase} +\title{Default passphrase} +\usage{ +oauth_shiny_app_passphrase() +} +\description{ +Default passphrase +} diff --git a/man/oauth_shiny_app_url.Rd b/man/oauth_shiny_app_url.Rd new file mode 100644 index 00000000..5ba83873 --- /dev/null +++ b/man/oauth_shiny_app_url.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-app.R +\name{oauth_shiny_app_url} +\alias{oauth_shiny_app_url} +\title{Override app url for OAuth} +\usage{ +oauth_shiny_app_url() +} +\description{ +It can be difficult to correctly infer the correct app url depending on +which environment the app is running in (localhost, shinyapps, cloud, etc). +httr2 makes an attempt to guess the correct app url, but the environment +variable \code{HTTR2_OAUTH_APP_URL} could be used to override a wrong guess. +} diff --git a/man/oauth_shiny_callback_resolve_client.Rd b/man/oauth_shiny_callback_resolve_client.Rd new file mode 100644 index 00000000..eeca5a10 --- /dev/null +++ b/man/oauth_shiny_callback_resolve_client.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-http-handlers.R +\name{oauth_shiny_callback_resolve_client} +\alias{oauth_shiny_callback_resolve_client} +\title{Resolve OAuth Client in Callback} +\usage{ +oauth_shiny_callback_resolve_client(client_config, query) +} +\arguments{ +\item{client_config}{A list of client configurations used for OAuth.} + +\item{query}{A list of query parameters from the OAuth callback request.} +} +\value{ +The matching client configuration object. +} +\description{ +This function resolves which OAuth client configuration should be used during +the callback phase based on the query parameters returned by the OAuth +server. It ensures that the client matches either the state parameter or the +OpenID issuer URL. +} +\keyword{internal} diff --git a/man/oauth_shiny_client.Rd b/man/oauth_shiny_client.Rd new file mode 100644 index 00000000..9ead44ae --- /dev/null +++ b/man/oauth_shiny_client.Rd @@ -0,0 +1,140 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client} +\alias{oauth_shiny_client} +\title{Create an OAuth Shiny Client} +\usage{ +oauth_shiny_client( + name, + id, + secret = NULL, + auth_url = NULL, + token_url = NULL, + redirect_uri = oauth_redirect_uri(), + openid_issuer_url = NULL, + openid_claims = c("aud", "sub", "name", "email"), + scope = NULL, + auth_params = list(), + token_params = list(), + pkce = TRUE, + postprocess_token = NULL, + auth_provider = FALSE, + auth_provider_primary = FALSE, + auth_set_custom_claim = NULL, + login_button = NULL, + login_button_dark = NULL, + login_path = paste0("login/", name), + logout_path = paste0("logout/", name), + client_cookie_name = paste0("oauth_app_", name, "_token"), + access_token_validity = 0 +) +} +\arguments{ +\item{name}{The name of the OAuth Provider. Used to provide an easy reference +to the client. Also used to derive the name of the login-endpoint (e.g. +/login/github), client cookie name (e.g. oauth_app_github_token). Should be +a valid URI path.} + +\item{id}{Client identifier} + +\item{secret}{Client secret. For most apps, this is technically confidential +so in principle you should avoid storing it in source code. However, many +APIs require it in order to provide a user friendly authentication +experience, and the risks of including it are usually low. To make things a +little safer, I recommend using \code{\link[=obfuscate]{obfuscate()}} when recording the client +secret in public code.} + +\item{auth_url}{Authorization url. If the client does not follow the OpenID +specification you'll need to discover this by reading the documentation. +Otherwise, you could supply the \code{openid_issuer_url}. Supplying an +\code{auth_url} will take precedence over any urls discovered by +\code{openid_issuer_url}.} + +\item{token_url}{Url to retrieve an access token.} + +\item{redirect_uri}{URL to redirect back to after authorization is complete. +Often this must be registered with the API in advance.} + +\item{openid_issuer_url}{If the provider follows the openid specification, +provide the issuer url. Do not include the path to the configuration +endpoint (also known as Well-Known Configuration Endpoint). Examples +include +\verb{https://login.microsoftonline.com/TENANT/v2.0} +(microsoft) and \verb{https://accounts.google.com/} (google).} + +\item{openid_claims}{Character vector of claims to be retrieved if +\code{openid_issuer_url} is not NULL. These claims will be included in the app +token to easily retrieve user information in shiny. Defaults to \code{name}, +\code{email}, \code{aud} and \code{sub}. See Section 5.1. Standard Claims of +\href{https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims}{OpenID spec}.} + +\item{scope}{Scopes to be requested from the resource owner.} + +\item{auth_params}{A list containing additional parameters passed to +\code{\link[=oauth_flow_auth_code_url]{oauth_flow_auth_code_url()}}.} + +\item{token_params}{List containing additional parameters passed to the +\code{token_url}.} + +\item{pkce}{Use "Proof Key for Code Exchange"? This adds an extra layer of +security and should always be used if supported by the server.} + +\item{postprocess_token}{A custom function for postprocessing the token, e.g. +token exchange or verification} + +\item{auth_provider}{Whether the OAuth client should be used to restrict +access to the shiny application. An OAuth App could often have multiple +auth providers (e.g. Apple, and Google), sometimes described as "social +login". If set to \code{TRUE}, this will generate login buttons for the client +when the standard UIs for login (\code{oauth_shiny_ui_login()}) and logout are +used. If set to \code{FALSE}, the OAuth Client is not used to restrict access to +the app, but could still be used to perform actions against the OAuth +provider when the user has succesfully logged in to the application.} + +\item{auth_provider_primary}{If the user has a preferred provider, this +could be used to automatically redirect the user to follow the OAuth dance +without displaying a UI for logigng in. A typical scenario could be an +enterprise application where the a non authorized user is always redirected +to login at a single identity provider (e.g. Microsoft Entra). Set +\code{login_ui} to NULL to enable direct login} + +\item{auth_set_custom_claim}{If the OAuth provider does not follow the +openid specification, a custom function could be used to set the audience +(aud) and subject (sub) for the app token. This function should take +\code{client} and \code{token} as input arguments. See +\code{oauth_shiny_client_github_set_custom_claim()}} + +\item{login_button}{A button typically generated by +\code{oauth_shiny_ui_button} which can be used in the login UI} + +\item{login_button_dark}{A button typically generated by +\code{oauth_shiny_ui_button} which can be used in the login UI (dark +theme)} + +\item{login_path}{Endpoint where users will be redirected to login for the +client, e.g. \verb{/login/github}. Typically not overridden.} + +\item{logout_path}{Endpoint where users will be redirected to login for the +client, e.g. \verb{/logout/github} Typically not overridden.} + +\item{client_cookie_name}{The name of the client cookie which contains the +access_token (and potentially id_token) for the client} + +\item{access_token_validity}{Validity duration for the client cookie which +contains the access token. If you want to make the client token available +in the application, set this to a value > \code{0}, e.g. \code{3600}. If set to +\code{NULL} the cookie will have the same validity as the token if specified and +will be unavailable if the token does not contain an expiry time (e.g. +Github). Regardless of the value set, the cookie will not expire later than +the token itself expires} +} +\description{ +The OAuth Shiny Client object allows you to use an oauth client in a shiny +application and supports two scenarios: (i) Restricting access to a shiny +application and (ii) performing actions on behalf of the user (behind an open +or restricted shiny application). If the OAuth Provider follows the \href{https://openid.net/specs/openid-connect-core-1_0.html}{OpenID specification} , the +\code{openid_issuer_url} can be used to retrieve endpoints and keys to perform and +verify the OAuth dance. If the provider does not support OpenID (e.g. +Github), then the user needs to figure this out on its own and provide +necessary endpoints (e.g \code{auth_url} and \code{token_url}) themselves. +} diff --git a/man/oauth_shiny_client_config.Rd b/man/oauth_shiny_client_config.Rd new file mode 100644 index 00000000..74f8d937 --- /dev/null +++ b/man/oauth_shiny_client_config.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_config} +\alias{oauth_shiny_client_config} +\title{Configure Multiple OAuth Shiny Clients} +\usage{ +oauth_shiny_client_config(...) +} +\arguments{ +\item{...}{A series of OAuth Shiny Client objects created by +\code{oauth_shiny_client()} or its specialized versions.} +} +\value{ +A named list of OAuth Shiny Client objects. The names of the list +elements correspond to the \code{name} parameter of each client. +} +\description{ +This function allows you to configure multiple OAuth Shiny Clients by +combining them into a single list. This is useful when your Shiny application +supports multiple OAuth providers (e.g., Google, Microsoft, GitHub, etc.). +} +\details{ +Each client in the list must be an object created by the +\code{oauth_shiny_client()} function or one of its specialized versions (e.g., +\code{oauth_shiny_client_google()}). +} +\examples{ +\dontrun{ +google_client <- oauth_shiny_client_google() +microsoft_client <- oauth_shiny_client_microsoft() +config <- oauth_shiny_client_config(google_client, microsoft_client) +} + +} diff --git a/man/oauth_shiny_client_github.Rd b/man/oauth_shiny_client_github.Rd new file mode 100644 index 00000000..ac999700 --- /dev/null +++ b/man/oauth_shiny_client_github.Rd @@ -0,0 +1,59 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_github} +\alias{oauth_shiny_client_github} +\title{Create a Github OAuth Client in Shiny} +\usage{ +oauth_shiny_client_github( + name = "github", + id = Sys.getenv("OAUTH_GITHUB_CLIENT_ID"), + secret = Sys.getenv("OAUTH_GITHUB_CLIENT_SECRET"), + redirect_uri = oauth_redirect_uri(), + scope = "read:user user:email", + login_button = oauth_shiny_ui_button_github(), + login_button_dark = oauth_shiny_ui_button_github_dark(), + auth_set_custom_claim = oauth_shiny_client_github_set_custom_claim, + ... +) +} +\arguments{ +\item{name}{The name of the OAuth Provider, default is "github".} + +\item{id}{Client ID, defaults to environment variable +\code{OAUTH_GITHUB_CLIENT_ID}.} + +\item{secret}{Client Secret, defaults to environment variable +\code{OAUTH_GITHUB_CLIENT_SECRET}.} + +\item{redirect_uri}{Authorization callback URL, defaults to +\code{oauth_redirect_uri()}.} + +\item{scope}{Scopes to be requested, defaults to "read:user user:email".} + +\item{login_button}{Button used for GitHub login, defaults to +\code{oauth_shiny_ui_button_github()}.} + +\item{login_button_dark}{Dark theme button for GitHub login, defaults to +\code{oauth_shiny_ui_button_github_dark()}.} + +\item{auth_set_custom_claim}{Custom function for setting claims, defaults +to \code{oauth_shiny_client_github_set_custom_claim}.} + +\item{...}{Additional arguments passed to \code{oauth_shiny_client()}.} +} +\value{ +A Shiny OAuth Client configured for GitHub. +} +\description{ +This function creates an OAuth Shiny Client specifically for GitHub. It uses +the general \code{oauth_shiny_client} function to set up the client with +GitHub-specific defaults. Note that Github does not currently support openid, +hence it requires additional requests to \verb{/user/email} to authorize the user. +Create the app from your \href{https://github.com/settings/developers}{Developer Settings} +} +\examples{ +\dontrun{ +spotify_client <- oauth_shiny_client_spotify() +} + +} diff --git a/man/oauth_shiny_client_github_set_custom_claim.Rd b/man/oauth_shiny_client_github_set_custom_claim.Rd new file mode 100644 index 00000000..d869e8cc --- /dev/null +++ b/man/oauth_shiny_client_github_set_custom_claim.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_github_set_custom_claim} +\alias{oauth_shiny_client_github_set_custom_claim} +\title{Set Custom Claims for GitHub OAuth Client} +\usage{ +oauth_shiny_client_github_set_custom_claim(client, token) +} +\arguments{ +\item{client}{An OAuth Shiny Client object created by +\code{oauth_shiny_client_github}.} + +\item{token}{The OAuth token object containing \code{access_token}.} +} +\value{ +A list of custom claims, including \code{sub} (email), \code{name} (user's +name), and \code{provider} (provider name, "github.com"). +} +\description{ +This function retrieves user information from GitHub and sets custom claims +for the OAuth token. The claims include the user's primary email and name. +} +\details{ +This function is typically used with the GitHub OAuth client created by +\code{oauth_shiny_client_github}. +} +\examples{ +\dontrun{ +claims <- oauth_shiny_client_github_set_custom_claim(client, token) +} + +} diff --git a/man/oauth_shiny_client_google.Rd b/man/oauth_shiny_client_google.Rd new file mode 100644 index 00000000..d9de0caa --- /dev/null +++ b/man/oauth_shiny_client_google.Rd @@ -0,0 +1,56 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_google} +\alias{oauth_shiny_client_google} +\title{Create an OAuth Shiny Client for Google} +\usage{ +oauth_shiny_client_google( + name = "google", + id = Sys.getenv("OAUTH_GOOGLE_CLIENT_ID"), + secret = Sys.getenv("OAUTH_GOOGLE_CLIENT_SECRET"), + openid_issuer_url = "https://accounts.google.com/", + scope = "openid profile email", + login_button = oauth_shiny_ui_button_google(), + login_button_dark = oauth_shiny_ui_button_google_dark(), + ... +) +} +\arguments{ +\item{name}{The name of the OAuth Provider, default is "google".} + +\item{id}{Client ID, defaults to environment variable +\code{OAUTH_GOOGLE_CLIENT_ID}.} + +\item{secret}{Client Secret, defaults to environment variable +\code{OAUTH_GOOGLE_CLIENT_SECRET}.} + +\item{openid_issuer_url}{URL for OpenID issuer, defaults to +"https://accounts.google.com/".} + +\item{scope}{Scopes to be requested, defaults to "openid profile email".} + +\item{login_button}{Button used for Google login, defaults to +\code{oauth_shiny_ui_button_google()}.} + +\item{login_button_dark}{Dark theme button for Google login, defaults to +\code{oauth_shiny_ui_button_google_dark()}.} + +\item{...}{Additional arguments passed to \code{oauth_shiny_client()}.} +} +\value{ +A Shiny OAuth Client configured for Google. +} +\description{ +This function creates an OAuth Shiny Client specifically for Google. It uses +the general \code{oauth_shiny_client} function to set up the client with +Google-specific defaults. +} +\details{ +For parameter details, see \code{\link[=oauth_shiny_client]{oauth_shiny_client()}}. +} +\examples{ +\dontrun{ +google_client <- oauth_shiny_client_google() +} + +} diff --git a/man/oauth_shiny_client_microsoft.Rd b/man/oauth_shiny_client_microsoft.Rd new file mode 100644 index 00000000..514f7e65 --- /dev/null +++ b/man/oauth_shiny_client_microsoft.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_microsoft} +\alias{oauth_shiny_client_microsoft} +\title{Create an OAuth Shiny Client for Microsoft} +\usage{ +oauth_shiny_client_microsoft( + name = "microsoft", + id = Sys.getenv("OAUTH_MICROSOFT_CLIENT_ID"), + secret = Sys.getenv("OAUTH_MICROSOFT_CLIENT_SECRET"), + openid_issuer_url, + scope = "openid profile email", + login_button = oauth_shiny_ui_button_microsoft(), + login_button_dark = oauth_shiny_ui_button_microsoft_dark(), + ... +) +} +\arguments{ +\item{name}{The name of the OAuth Provider, default is "microsoft".} + +\item{id}{Client ID, defaults to environment variable +\code{OAUTH_MICROSOFT_CLIENT_ID}.} + +\item{secret}{Client Secret, defaults to environment variable +\code{OAUTH_MICROSOFT_CLIENT_SECRET}.} + +\item{openid_issuer_url}{URL for OpenID issuer.} + +\item{scope}{Scopes to be requested, defaults to "openid profile email".} + +\item{login_button}{Button used for Microsoft login, defaults to +\code{oauth_shiny_ui_button_microsoft()}.} + +\item{login_button_dark}{Dark theme button for Microsoft login, defaults to +\code{oauth_shiny_ui_button_microsoft_dark()}.} + +\item{...}{Additional arguments passed to \code{oauth_shiny_client()}.} +} +\value{ +A Shiny OAuth Client configured for Microsoft. +} +\description{ +This function creates an OAuth Shiny Client specifically for Microsoft. It +uses the general \code{oauth_shiny_client} function to set up the client with +Microsoft-specific defaults. +} +\details{ +For parameter details, see \code{\link[=oauth_shiny_client]{oauth_shiny_client()}}. +} +\examples{ +\dontrun{ +microsoft_client <- oauth_shiny_client_microsoft() +} + +} diff --git a/man/oauth_shiny_client_spotify.Rd b/man/oauth_shiny_client_spotify.Rd new file mode 100644 index 00000000..aa4a4ead --- /dev/null +++ b/man/oauth_shiny_client_spotify.Rd @@ -0,0 +1,56 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_spotify} +\alias{oauth_shiny_client_spotify} +\title{This function creates an OAuth Shiny Client specifically for Spotify. It uses +the general \code{oauth_shiny_client} function to set up the client with +Spotify-specific defaults.} +\usage{ +oauth_shiny_client_spotify( + name = "spotify", + id = Sys.getenv("OAUTH_SPOTIFY_CLIENT_ID"), + secret = Sys.getenv("OAUTH_SPOTIFY_CLIENT_SECRET"), + redirect_uri = oauth_redirect_uri(), + scope = "user-read-email", + login_button = oauth_shiny_ui_button_spotify(), + login_button_dark = oauth_shiny_ui_button_spotify_dark(), + auth_set_custom_claim = oauth_shiny_client_spotify_set_custom_claim, + ... +) +} +\arguments{ +\item{name}{The name of the OAuth Provider, default is "spotify".} + +\item{id}{Client ID, defaults to environment variable +\code{OAUTH_SPOTIFY_CLIENT_ID}.} + +\item{secret}{Client Secret, defaults to environment variable +\code{OAUTH_SPOTIFY_CLIENT_SECRET}.} + +\item{redirect_uri}{Authorization callback URL, defaults to +\code{oauth_redirect_uri()}.} + +\item{scope}{Scopes to be requested, defaults to "user-read-email".} + +\item{login_button}{Button used for Spotify login, defaults to +\code{oauth_shiny_ui_button_spotify()}.} + +\item{login_button_dark}{Dark theme button for Spotify login, defaults to +\code{oauth_shiny_ui_button_spotify_dark()}.} + +\item{auth_set_custom_claim}{Custom function for setting claims, defaults +to \code{oauth_shiny_client_spotify_set_custom_claim}.} + +\item{...}{Additional arguments passed to \code{oauth_shiny_client()}.} +} +\value{ +A Shiny OAuth Client configured for Spotify. +} +\description{ +For parameter details, see \code{\link[=oauth_shiny_client]{oauth_shiny_client()}}. +} +\examples{ +\dontrun{ +spotify_client <- oauth_shiny_client_spotify() +} +} diff --git a/man/oauth_shiny_client_spotify_set_custom_claim.Rd b/man/oauth_shiny_client_spotify_set_custom_claim.Rd new file mode 100644 index 00000000..89e71a27 --- /dev/null +++ b/man/oauth_shiny_client_spotify_set_custom_claim.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-client.R +\name{oauth_shiny_client_spotify_set_custom_claim} +\alias{oauth_shiny_client_spotify_set_custom_claim} +\title{Set Custom Claims for Spotify OAuth Client} +\usage{ +oauth_shiny_client_spotify_set_custom_claim(client, token) +} +\arguments{ +\item{client}{An OAuth Shiny Client object created by +\code{oauth_shiny_client_spotify}.} + +\item{token}{The OAuth token object containing \code{access_token}.} +} +\value{ +A list of custom claims, including \code{sub} (email), \code{aud} (audience, +"spotify.com"), and \code{name} (display name). +} +\description{ +This function retrieves user information from Spotify and sets custom claims +for the OAuth token. The claims include the user's email and display name. +} +\details{ +This function is typically used with the Spotify OAuth client created by +\code{oauth_shiny_client_spotify}. +} +\examples{ +\dontrun{ +# Assuming `spotify_client` is created with `oauth_shiny_client_spotify()` +claims <- oauth_shiny_client_spotify_set_custom_claim(spotify_client, token) +} + +} diff --git a/man/oauth_shiny_decrypt_client_token.Rd b/man/oauth_shiny_decrypt_client_token.Rd new file mode 100644 index 00000000..e52a416d --- /dev/null +++ b/man/oauth_shiny_decrypt_client_token.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_decrypt_client_token} +\alias{oauth_shiny_decrypt_client_token} +\title{Decrypt OAuth Access Token} +\usage{ +oauth_shiny_decrypt_client_token(cookie, key) +} +\arguments{ +\item{cookie}{A string representing the encrypted cookie value.} + +\item{key}{A secret key used to decrypt the cookie.} +} +\value{ +A decrypted OAuth token object. +} +\description{ +This function decrypts an OAuth access token stored in a cookie. +} diff --git a/man/oauth_shiny_encrypt_client_token.Rd b/man/oauth_shiny_encrypt_client_token.Rd new file mode 100644 index 00000000..eb9da55a --- /dev/null +++ b/man/oauth_shiny_encrypt_client_token.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_encrypt_client_token} +\alias{oauth_shiny_encrypt_client_token} +\title{Encrypt OAuth Client Access Token} +\usage{ +oauth_shiny_encrypt_client_token(client, token, key, max_age) +} +\arguments{ +\item{client}{A list representing the client configuration, including the +client secret.} + +\item{token}{A list representing the OAuth token to be encrypted.} + +\item{key}{A secret key used to encrypt the token.} + +\item{max_age}{Integer specifying the expiration time of the token in +seconds.} +} +\value{ +A string representing the encrypted token in hexadecimal format. +} +\description{ +This function encrypts an OAuth access token for storage in a cookie. +} diff --git a/man/oauth_shiny_get_access_token.Rd b/man/oauth_shiny_get_access_token.Rd new file mode 100644 index 00000000..04878c02 --- /dev/null +++ b/man/oauth_shiny_get_access_token.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_get_access_token} +\alias{oauth_shiny_get_access_token} +\title{Get OAuth Client Access Token from Shiny App Cookie} +\usage{ +oauth_shiny_get_access_token( + client, + key = oauth_shiny_app_passphrase(), + session = shiny::getDefaultReactiveDomain() +) +} +\arguments{ +\item{client}{A list representing the client configuration, +including the client name and secret.} + +\item{key}{A secret key used to decrypt the token.} + +\item{session}{The current Shiny session object, used to access request +details. Default is \code{shiny::getDefaultReactiveDomain()}.} +} +\value{ +A decrypted \code{oauth_token} if the cookie exists and is valid +} +\description{ +This function retrieves and decrypts an OAuth token stored in a cookie. +It automatically handles chunked cookies if the encrypted token exceeds +maximum cookie size (e.g. \code{oauth_app_google_token_1} and +\code{oauth_app_google_token2}) +} diff --git a/man/oauth_shiny_get_app_token.Rd b/man/oauth_shiny_get_app_token.Rd new file mode 100644 index 00000000..df20dd6c --- /dev/null +++ b/man/oauth_shiny_get_app_token.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_get_app_token} +\alias{oauth_shiny_get_app_token} +\title{Get OAuth Token from Shiny App Cookie} +\usage{ +oauth_shiny_get_app_token( + cookie = "oauth_app_token", + key = oauth_shiny_app_passphrase(), + session = shiny::getDefaultReactiveDomain() +) +} +\arguments{ +\item{cookie}{A string representing the name of the cookie where the JWT is +stored. Default is \code{oauth_app_token}.} + +\item{key}{The secret key used in the application.} + +\item{session}{Defaults to \code{shiny::getDefaultReactiveDomain()}} +} +\value{ +A decoded JWT if the cookie exists and is valid; otherwise, \code{NULL}. +} +\description{ +This function retrieves and decodes the JWT stored in a specified cookie from +the incoming request. +} diff --git a/man/oauth_shiny_get_app_token_from_request.Rd b/man/oauth_shiny_get_app_token_from_request.Rd new file mode 100644 index 00000000..49ba5383 --- /dev/null +++ b/man/oauth_shiny_get_app_token_from_request.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_get_app_token_from_request} +\alias{oauth_shiny_get_app_token_from_request} +\title{Get OAuth Token from Shiny App Cookie} +\usage{ +oauth_shiny_get_app_token_from_request(req, cookie = "oauth_app_token", key) +} +\arguments{ +\item{req}{The incoming request object from which the cookie is to be +extracted.} + +\item{cookie}{A string representing the name of the cookie where the JWT is +stored. Default is \code{oauth_app_token}.} + +\item{key}{The secret key used in the application.} +} +\value{ +A decoded JWT if the cookie exists and is valid; otherwise, \code{NULL}. +} +\description{ +This function retrieves and decodes the JWT stored in a specified cookie from +the incoming request. +} diff --git a/man/oauth_shiny_infer_app_url.Rd b/man/oauth_shiny_infer_app_url.Rd new file mode 100644 index 00000000..7c215a5c --- /dev/null +++ b/man/oauth_shiny_infer_app_url.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-app.R +\name{oauth_shiny_infer_app_url} +\alias{oauth_shiny_infer_app_url} +\title{Extract server URL from the request} +\usage{ +oauth_shiny_infer_app_url(req) +} +\arguments{ +\item{req}{A request object.} +} +\value{ +The app url. +} +\description{ +Inferring the correct app url on the server requires some work. +This function attempts to guess the correct server url, but may fail outside +of tested hosts (\verb{127.0.0.1} and \code{shinyapps.io}). To be sure, set the +environment variable \code{HTTR2_OAUTH_APP_URL} explicitly. Logic inspired by +\href{r4ds/shinyslack}{https://github.com/r4ds/shinyslack}. +} +\keyword{internal} diff --git a/man/oauth_shiny_set_access_token.Rd b/man/oauth_shiny_set_access_token.Rd new file mode 100644 index 00000000..54266a49 --- /dev/null +++ b/man/oauth_shiny_set_access_token.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_set_access_token} +\alias{oauth_shiny_set_access_token} +\title{Set OAuth Client Access Token in Shiny App Cookie} +\usage{ +oauth_shiny_set_access_token(client, token, key) +} +\arguments{ +\item{client}{An \code{oauth_shiny_client} representing the client configuration} + +\item{token}{A list representing the OAuth token to be stored.} + +\item{key}{A secret key used for encrypting the token.} +} +\value{ +None. The function sets the encrypted token in the HTTP response +header as a cookie. +} +\description{ +This function stores an OAuth access token in a cookie, encrypting it before +storage. The token is an \code{oauth_token} object containing access_token and +related information. +} diff --git a/man/oauth_shiny_set_app_token.Rd b/man/oauth_shiny_set_app_token.Rd new file mode 100644 index 00000000..ad474d87 --- /dev/null +++ b/man/oauth_shiny_set_app_token.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-token.R +\name{oauth_shiny_set_app_token} +\alias{oauth_shiny_set_app_token} +\title{Set OAuth Token in Shiny App Cookie} +\usage{ +oauth_shiny_set_app_token(claims, cookie, key, token_validity) +} +\arguments{ +\item{claims}{A list containing the claims to be included in the JWT.} + +\item{cookie}{A string representing the name of the cookie where the JWT will +be stored.} + +\item{key}{A secret key used to sign the JWT.} + +\item{token_validity}{Integer specifying the expiration time of the JWT in +seconds.} +} +\value{ +None. The function sets the cookie in the HTTP response header. +} +\description{ +This function sets a JSON Web Token (JWT) and stores it in a cookie so the +user can be successfully identified and skip login when returning to the app. +The token is stored in a httponly, secure cookie and signed with HMAC. Claims +include \code{identifier}, \code{name}, \code{email}, \code{aud} and \code{sub}. +} diff --git a/man/oauth_shiny_ui_button.Rd b/man/oauth_shiny_ui_button.Rd new file mode 100644 index 00000000..42c25f6f --- /dev/null +++ b/man/oauth_shiny_ui_button.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-ui.R +\name{oauth_shiny_ui_button} +\alias{oauth_shiny_ui_button} +\title{Create a Custom OAuth Shiny UI Login Button} +\usage{ +oauth_shiny_ui_button(path, title, logo = NULL, theme = c("light", "dark")) +} +\arguments{ +\item{path}{The URL path for the OAuth login.} + +\item{title}{The title to display on the button.} + +\item{logo}{The file path or SVG code for the logo to display on the button.} + +\item{theme}{The theme of the button, either "light" or "dark". Defaults to +"light".} +} +\value{ +A Shiny UI object representing the login button. +} +\description{ +This function creates a custom login button for an OAuth provider, allowing +customization of the button's appearance. +} diff --git a/man/oauth_shiny_ui_login.Rd b/man/oauth_shiny_ui_login.Rd new file mode 100644 index 00000000..ba9e6181 --- /dev/null +++ b/man/oauth_shiny_ui_login.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-ui.R +\name{oauth_shiny_ui_login} +\alias{oauth_shiny_ui_login} +\title{Create an OAuth Shiny UI Login Page} +\usage{ +oauth_shiny_ui_login(oauth_shiny_client_config, dark_mode = FALSE) +} +\arguments{ +\item{oauth_shiny_client_config}{A list of client configurations for OAuth +providers.} + +\item{dark_mode}{A logical value indicating whether to use dark mode. +Defaults to \code{FALSE}.} +} +\value{ +A Shiny UI object representing the login page. +} +\description{ +This function creates the Shiny UI for the OAuth login page, allowing users +to log in with their preferred OAuth provider. The UI can be customized for +dark mode. +} diff --git a/man/oauth_shiny_ui_logout.Rd b/man/oauth_shiny_ui_logout.Rd new file mode 100644 index 00000000..bd4b35c6 --- /dev/null +++ b/man/oauth_shiny_ui_logout.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/oauth-shiny-ui.R +\name{oauth_shiny_ui_logout} +\alias{oauth_shiny_ui_logout} +\title{Create an OAuth Shiny UI Logout Page} +\usage{ +oauth_shiny_ui_logout(oauth_shiny_client_config, dark_mode = FALSE) +} +\arguments{ +\item{oauth_shiny_client_config}{A list of client configurations for OAuth +providers.} + +\item{dark_mode}{A logical value indicating whether to use dark mode. +Defaults to \code{FALSE}.} +} +\value{ +A Shiny UI object representing the logout page. +} +\description{ +This function creates the Shiny UI for the OAuth logout page, displaying a +message that the user has successfully logged out. +} diff --git a/vignettes/articles/images/shiny_app_client_token.png b/vignettes/articles/images/shiny_app_client_token.png new file mode 100644 index 00000000..d1835734 Binary files /dev/null and b/vignettes/articles/images/shiny_app_client_token.png differ diff --git a/vignettes/articles/images/shiny_app_client_userinfo.png b/vignettes/articles/images/shiny_app_client_userinfo.png new file mode 100644 index 00000000..e75e8475 Binary files /dev/null and b/vignettes/articles/images/shiny_app_client_userinfo.png differ diff --git a/vignettes/articles/images/shiny_app_cookies.png b/vignettes/articles/images/shiny_app_cookies.png new file mode 100644 index 00000000..5a415fc3 Binary files /dev/null and b/vignettes/articles/images/shiny_app_cookies.png differ diff --git a/vignettes/articles/images/shiny_app_example.gif b/vignettes/articles/images/shiny_app_example.gif new file mode 100644 index 00000000..38539e39 Binary files /dev/null and b/vignettes/articles/images/shiny_app_example.gif differ diff --git a/vignettes/articles/images/shiny_app_login.png b/vignettes/articles/images/shiny_app_login.png new file mode 100644 index 00000000..4a38458e Binary files /dev/null and b/vignettes/articles/images/shiny_app_login.png differ diff --git a/vignettes/articles/images/shiny_app_oauth_handlers_and_flow.png b/vignettes/articles/images/shiny_app_oauth_handlers_and_flow.png new file mode 100644 index 00000000..391ff810 Binary files /dev/null and b/vignettes/articles/images/shiny_app_oauth_handlers_and_flow.png differ diff --git a/vignettes/articles/shiny.Rmd b/vignettes/articles/shiny.Rmd new file mode 100644 index 00000000..7dbd3fb2 --- /dev/null +++ b/vignettes/articles/shiny.Rmd @@ -0,0 +1,629 @@ +--- +title: "Integrating OAuth 2.0 in Shiny Applications Using httr2" +editor_options: + markdown: + wrap: 72 +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +``` + +```{r setup} +library(httr2) +``` + +This vignette demonstrates how to integrate OAuth 2.0 authorization into your +Shiny applications using the httr2 package. The `oauth_shiny_app()` function +streamlines the OAuth workflow, handling the authentication process behind the scenes. + +By the end of this vignette, you should understand how to: + +- Enforce user login before accessing your Shiny app. + +- Retrieve OAuth tokens on behalf of users to interact with external APIs. + +- Set up custom and standard OAuth Shiny clients (e.g., GitHub, Google, Spotify). + +- Customize the user login and logout experience. + +- Deploy an OAuth-enabled Shiny app to the cloud. + +## Getting started + +### Prerequisites + +Before diving in, you need to register an OAuth client. For quick setup, we +recommend using GitHub from your +[Developer Settings](https://github.com/settings/developers), +though other providers can also be used. + +Set your OAuth credentials as environment variables: + +```{r preqrequisites_client, eval=FALSE} +Sys.setenv(OAUTH_GITHUB_CLIENT_ID = "GithubClientID") +Sys.setenv(OAUTH_GITHUB_CLIENT_SECRET = "GithubClientSecret") +``` + +Next, configure your Shiny app's port and set up a secure passphrase for signing +and encrypting cookies. The callback URL should be set to 127.0.0.1:{port} +(not localhost). Ensure the port matches your Shiny application’s port. + +```{r preqrequisites_other} +options(shiny.port = 1410) +options(shiny.launch.browser = TRUE) +Sys.setenv(HTTR2_OAUTH_PASSPHRASE = "MySecurePassPhraseHere") +Sys.setenv(HTTR2_OAUTH_REDIRECT_URL = "http://127.0.0.1:1410") +``` + +### Setting up OAuth clients + +To set up an OAuth Shiny client, use the family of `oauth_shiny_client_*()` +functions provided by `httr2`. The package includes pre-configured clients for +popular providers like GitHub, Google, Microsoft, and Spotify. + +Here’s an example of setting up a GitHub OAuth client: + +```{r shiny_client_github} +oauth_shiny_client_github() +``` +You can easily configure multiple clients using `oauth_shiny_client_config()`, +which accepts a list of clients: + +```{r shiny_client_config} +oauth_shiny_client_config( + oauth_shiny_client_github(), + oauth_shiny_client_spotify() +) +``` + +### Testing your OAuth configuration + +The httr2 package includes an example app you can run to test your +config: + +```{r myfirstoauthapp, eval=FALSE} +config <- oauth_shiny_client_config( + oauth_shiny_client_github( + auth_provider = TRUE, # Shiny client should be used to restrict app access + access_token_validity = 600 # Expiry time of access token cookie + ) +) + +oauth_shiny_app_example(config) +``` + +This example app provides a login UI for GitHub. Upon successful login, you +should see an access token displayed, which expires in ten minutes. The app +also shows your name in the top right corner, with a logout timer set to 60 +minutes by default. You can customize this with the token_validity parameter. +`token_validity` parameter. + + + +### Creating a Shiny OAuth Application + +Once your configuration works as expected, you can create your own Shiny app +with OAuth integration. This is as simple as passing your Shiny app object into +`oauth_shiny_app()`. + +Let’s create an example app that prints the OAuth token retrieved from GitHub: + +```{r message = FALSE, eval = FALSE} +library(shiny) + +config <- oauth_shiny_client_config( + oauth_shiny_client_github( + auth_provider = TRUE, + access_token_validity = 3600 + ) +) + +ui <- fluidPage( + h1("Logged in"), + verbatimTextOutput("token", TRUE), + a(href = "logout", "Logout") +) + +server <- function(input, output, session) { + output$token <- renderText({ + token <- oauth_shiny_get_access_token(config$github) + token[["access_token"]] + }) +} + +shinyApp(ui, server) |> + oauth_shiny_app(config) +``` + + + +If you haven't logged out of the example app, you shouldn't need to log in +again. httr2 caches tokens as secure HttpOnly cookies. If you did log out, +GitHub will silently redirect you back to the app without requiring reauthorization. + +### Disabling authentication + +You can create a public Shiny app while still allowing users to log in and +retrieve user information from GitHub. To disable mandatory authentication, +set `require_auth = FALSE`. + +Here’s a simple example: + +```{r, message = FALSE, eval = FALSE} +library(shiny) + +config <- oauth_shiny_client_config( + oauth_shiny_client_github( + auth_provider = TRUE, + access_token_validity = 3600 + ) +) + +ui <- fluidPage( + h1("Publicly available Shiny App"), + uiOutput("button"), + p("Token:", textOutput("token", inline = TRUE)), + p("User info:", verbatimTextOutput("userinfo")) +) + +server <- function(input, output, session) { + token <- reactive(oauth_shiny_get_access_token(config$github)) + logged_in <- reactive(!is.null(token())) + # Render a login or logout button depending on whether the user is logged in + output$button <- renderUI({ + path <- if (logged_in()) "logout/github" else "login/github" + title <- if (logged_in()) "Log out of Github" else "Log in to Github" + oauth_shiny_ui_button_github(path, title) + }) + # Print token + output$token <- renderText(token()[["access_token"]]) + # Print userinfo from Github + output$userinfo <- renderPrint({ + req(token()) + request("https://api.github.com/user") |> + req_auth_bearer_token(token()$access_token) |> + req_perform() |> + resp_body_json() |> + str() + }) +} + +shinyApp(ui, server) |> + oauth_shiny_app(config, require_auth = FALSE) +``` + + + +### Limitations and Considerations + +#### State Loss During Redirection +Currently, Shiny OAuth apps lose state during the OAuth redirection process. +One potential workaround is to bookmark the application’s state and include it +in a state parameter during the OAuth flow. This approach might be feasible +for simple apps but could become unwieldy for more complex applications. + +#### Local Development + +- Use `http://127.0.0.1` instead of `http://localhost` as the redirect URL. +Cookies set at localhost won’t persist when using 127.0.0.1, causing the OAuth +flow to fail. + +- If using RStudio, set `options(shiny.launch.browser = TRUE)` to avoid issues +with the built-in browser, which does not support external redirects and OAuth +cookies. + +#### Shinyapps.io +Shinyapps.io generally works well for non-OpenID providers (e.g., GitHub and +Spotify). However, OpenID providers like Google and Microsoft may cause issues +due to the way shinyapps.io handles redirection. Specifically, the callback may +trigger a loading screen that replays the OAuth flow. This is an ongoing +issue with no known workaround at the time. + +#### Cloud Deployment +Shiny OAuth apps can be deployed as Docker containers, even on serverless +platforms like Azure Container Apps and Google Cloud Run. Ensure you set the +`HTTR2_OAUTH_APP_URL` environment variable to guarantee the correct server URL +is inferred. + +#### Shiny Server +Shiny Server is not compatible with Shiny OAuth apps because it +[strips cookies](https://groups.google.com/g/shiny-discuss/c/nHFbL0K38k8/m/FndeYifoAwAJ), +which are essential for maintaining authentication. + +## Understanding the OAuth Flow in Shiny with httr2 +The OAuth flow is triggered by http handlers which listen to login, logout +and callback endpoints as shown in the following figure: + + + +### Step 0: User visits a Shiny OAuth App +When a user accesses the app, `oauth_shiny_app()` checks for an existing +`oauth_app_token cookie`, which contains a signed JSON Web Token (JWT) with +claims such as `identifier`, `aud`, `sub`, and `email`. If the cookie is valid, +the user is allowed into the app; otherwise, they are redirected to the +login UI. + +To retrieve the app token within the server, use: + +```{r, eval = FALSE} +# In Shiny +token <- oauth_shiny_get_app_token() +``` + +You can inspect the cookie from your browser by navigating to +`Dev Tools > Applicaction`. + + + +It is also possible to inspect the claims by copying the cookie value to +your R console: + +```{r, eval=FALSE} +cookie_value <- "Your value here" +jose::jwt_split(cookie_value) +``` + +By default, `oauth_shiny_app()` has a `token_validity` set to one hour. +This ensures that both the JWT and the cookie (`max-age`) expires at +this time. The user may attempt to adjust the `max-age` of the cookie +manually, but this will fail signature verification when decoding the +JWT. + +If the `oauth_app_token` cookie does not exist or signature verification +of the cookie fails, the user is denied entry to the app and redirected +to `login_ui`. If signature verification succeeds, the user is let into +the app. + +To retrieve the app token from the server side in a shiny app (e.g. to +populate the user's name), use `oauth_shiny_get_app_token()`: + +```{r, eval=FALSE} +# In shiny +token <- oauth_shiny_get_app_token() +``` + +#### Client access tokens + +In addition to the app token (`oauth_app_token`), the user cookies can +also contain client access tokens. These cookies will typically be named +`oauth_app_token_{client}` (e.g. `oauth_app_token_spotify`) or similar. +They contain access tokens which the user has requested when an +`oauth_shiny_client` has `access_token_validity` set to a duration +larger than 0. These tokens are encrypted using `sodium` and can only be +decrypted using the passphrase. To request a decrypted token from the +server side in a shiny application, use +`oauth_shiny_get_access_token()`: + +```{r, eval=FALSE} +# In shiny +config <- oauth_shiny_client_config( + oauth_shiny_client_github( + access_token_validity = 3600 + ) +) +oauth_shiny_get_access_token(config$github) +``` + +### Step 1: User is prompted with login UI + +If `require_auth = TRUE,` users without a valid token are redirected to a login UI +(`login_ui`). The standard UI includes login buttons for each OAuth client. +You can also customize the login screen or disable it entirely. + + + +The login screen can also be disabled: + +```{r eval = FALSE} +oauth_shiny_app(config, login_ui = NULL) +``` + +This will instead route the user directly to the client for +authorization and requires setting one `oauth_shiny_client` as the +primary provider (`auth_provider_primary = TRUE)`. Automatically +redirecting a non-logged in user to the authentication provider may be +preferred in an enterprise context where the application is an internal +tool, but most users prefer to have a better understanding of the sign +in process. + +Login buttons contain links to automatically generated endpoints such as +`login/github/` and `login/spotify` based on the value of `login_path` +and `logout_path` of each `oauth_shiny_client()`. + +### Step 2: Redirection to OAuth Client + +When the user is redirected to the resource server of an +`oauth_shiny_client()`, a cookie with a randomly generated state and +PKCE verifier (if applicable) are set. The client ID is included as part +of the generated state to ensure that callbacks arriving at the same url +can be routed to their respective token endpoints if there are multiple +clients. + +### Step 3: Callback from OAuth Client, requesting token + +When the user is routed back to the application with an authorization +code, the state cookie is compared to the retrieved state to verify the +request. + +The authorization code is then used to request an access token from the +client. If the client is an OpenID provider, the signature of the token +is verified. Claims such as audience, subject, name and e-mail are then +retrieved either from the ID token (OpenID) or by using a custom claim +function, see `oauth_shiny_client_github_set_custom_claim()`. If the +client is an auth provider (`auth_provider = TRUE`), these claims +are then signed and stored in a cookie (`oauth_app_token`) with expiry +set to `token_validity`. This cookie ensures that an authenticated user +returning to the app can bypass login within the expiration period. + +By default, the access token of an OAuth Shiny client is not stored. +Setting `access_token_validity` to value larger than 0 will store this +value in an encrypted cookie, and it can be retrieved from the server +side using `oauth_shiny_get_access_token()` as seen in the example +above. + +```{r, eval = FALSE} +config <- oauth_shiny_client_config( + oauth_shiny_client_github( + auth_provider = TRUE, + access_token_validity = 3600 + ) +) + +# In shiny: Retrieve access token +oauth_shiny_get_access_token(config$github) +``` + +## Step 4: Logout + +When the user clicks logout, the user is redirected to the `logout` +endpoint where all app and client cookies are deleted before being +redirected to a logout page which can be customized with (`logout_ui`). + +It is also possible to just log out of a single provider which will +delete the corresponding client cookie, but not touch app cookies. +Redirect the user to `logout/{client}` in this case (e.g. +`logout/github`). + +A user could potentially keep the session alive beyond the validity of +the app token. If you want to automatically sign the user out when such +a case happens, use `oauth_shiny_app(..., logout_on_token_expiry = TRUE)`. + +## Custom clients + +To set up a custom client, use `oauth_shiny_client()` with `name`, `id` +and `secret`. If the OAuth clients does not confirm to the OpenID spec, +you would also need to supply `auth_url` and `token_url`. To test the +application you could either visit `login/{name}`-endpoint or use the +example application: + +```{r, eval = FALSE} +options(shiny.port = 1410) +options(shiny.launch.browser = TRUE) + +strava <- oauth_shiny_client( + name = "strava", + id = Sys.getenv("OAUTH_STRAVA_CLIENT_ID"), + secret = Sys.getenv("OAUTH_STRAVA_CLIENT_SECRET"), + auth_url = "https://www.strava.com/oauth/authorize", + token_url = "https://www.strava.com/api/v3/oauth/token", + pkce = FALSE, + scope = "read", + access_token_validity = 30 +) + +config <- oauth_shiny_client_config(strava) + +oauth_shiny_app_example(config, require_auth = FALSE) +``` + +### Adding login buttons + +If this works fine, we can add a sign-in button to the client. The logo +could either be specified as an inline SVG, a link to a local file or +omitted: + +```{r, eval = FALSE} +options(shiny.port = 1410) +options(shiny.launch.browser = TRUE) + +svg <- '' + +strava_button <- oauth_shiny_ui_button("login/strava", "Sign in with Strava", svg) +strava_button_dark <- oauth_shiny_ui_button("login/strava", "Sign in with Strava", svg, "dark") + +strava <- oauth_shiny_client( + name = "strava", + id = Sys.getenv("OAUTH_STRAVA_CLIENT_ID"), + secret = Sys.getenv("OAUTH_STRAVA_CLIENT_SECRET"), + auth_url = "https://www.strava.com/oauth/authorize", + token_url = "https://www.strava.com/api/v3/oauth/token", + pkce = FALSE, + scope = "read", + auth_provider = TRUE, + login_button = strava_button, + login_button_dark = strava_button_dark, + access_token_validity = 30 # For displaying token in example app +) + +config <- oauth_shiny_client_config(strava) + +oauth_shiny_app_example(config, require_auth = TRUE) +``` + +### Setting custom claims + +You may notice that the app does not contain the name of the user in the +top right corner. This is because the cookie `oauth_app_token` does not +contain any claims with `name`. Let's add this and some additional +claims to the cookie: + +```{r, eval = FALSE} +strava_custom_claims <- function(client, token) { + list( + sub = as.character(token$athlete$id), + aud = "strava.com", + name = paste(token$athlete$firstname, token$athlete$lastname) + ) +} +``` + +You may now pull everything together to a `oauth_shiny_client_strava()` +if you like, or just use it like below. Remember to log out of the application +and back in for your name to appear: + +```{r, eval = FALSE} +options(shiny.port = 1410) +options(shiny.launch.browser = TRUE) + +svg <- '' + +strava_button <- oauth_shiny_ui_button("login/strava", "Sign in with Strava", svg) +strava_button_dark <- oauth_shiny_ui_button("login/strava", "Sign in with Strava", svg, "dark") + +strava_custom_claims <- function(client, token) { + list( + sub = as.character(token$athlete$id), + aud = "strava.com", + name = paste(token$athlete$firstname, token$athlete$lastname) + ) +} + +strava <- oauth_shiny_client( + name = "strava", + id = Sys.getenv("OAUTH_STRAVA_CLIENT_ID"), + secret = Sys.getenv("OAUTH_STRAVA_CLIENT_SECRET"), + auth_url = "https://www.strava.com/oauth/authorize", + token_url = "https://www.strava.com/api/v3/oauth/token", + pkce = FALSE, + scope = "read", + auth_provider = TRUE, + auth_set_custom_claim = strava_custom_claims, + login_button = strava_button, + login_button_dark = strava_button_dark, + access_token_validity = 30 +) + +config <- oauth_shiny_client_config(strava) + +oauth_shiny_app_example(config, require_auth = TRUE) +``` + +## Custom OpenID clients + +For OpenID clients, all you need is a client ID, secret and a Discovery +Endpoint, often known as a Well-Known Endpoint such as +[https://accounts.google.com/.well-known/openid-configuration](). +Provide the `openid_issuer_url` which is the Discovery Document URL +without the `.well-known...` path: + +```{r, eval = FALSE} +google_openid <- oauth_shiny_client( + name = "google", + id = "GoogleClientID", + secret = "GoogleClientSecret", + openid_issuer = "https://accounts.google.com/" +) +``` + +See `oauth_shiny_client_google()` for an example which also includes the +necessary scopes and sign in buttons. + +Httr2 will request the Discovery Document to populate authorization and +token endpoints, in addition to fetching public keys for verifying the +signature of the retrieved ID token from the endpoint. + +### Microsoft setup + +Some OpenID providers will not have a common endpoint like the one +above, but requires you to specify the endpoint based on your tenant, +e.g. Microsoft: + +```{r, eval = FALSE} +oauth_shiny_client_microsoft( + openid_issuer_url = "https://login.microsoftonline.com/{TENANT}/v2.0", +) +``` + +## Deployment + +Apps should just work on cloud services like Azure Container Apps and +Cloud Run. Currently, httr2 can struggle with inferring the correct app +url for redirects. This will likely improve in the future, but for now +it is strongly advised to set the `HTTR2_OAUTH_APP_URL` variable to the +url of your application to ensure that this works as expected. Below is +a typical Dockerfile which should work: + +### Example application + +Located at `app/app.R`: + +```{r, eval = FALSE} +library(httr2) + +client_config <- oauth_shiny_client_config( + oauth_shiny_client_github(auth_provider = TRUE, access_token_validity = 30), + oauth_shiny_client_google(auth_provider = TRUE, access_token_validity = 30), + oauth_shiny_client_spotify(auth_provider = TRUE, access_token_validity = 30) +) + +oauth_shiny_app_example( + client_config, + require_auth = TRUE, + dark_mode = TRUE +) +``` + +### Dockerfile + +``` +FROM rocker/r-ver:4.4.0 + +COPY ./app ./app + +RUN apt-get update && \ + apt-get install -y \ + libcurl4-openssl-dev \ + libsodium-dev + +RUN install2.r -e \ + bslib \ + jose \ + openssl \ + remotes \ + shiny \ + sodium + +RUN R -e "remotes::install_github('thohan88/httr2', ref = 'oauth-shiny-auth')" + +EXPOSE 1410 +CMD ["R", "-e", "shiny::runApp('app', host='0.0.0.0', port=1410)"] +``` + +### Required environment variables + +Set these environment variables on the cloud runtime depending on your client config: + +``` +HTTR2_OAUTH_PASSPHRASE +HTTR2_OAUTH_APP_URL +HTTR2_OAUTH_REDIRECT_URL +OAUTH_MICROSOFT_CLIENT_ID +OAUTH_MICROSOFT_CLIENT_SECRET +OAUTH_GITHUB_CLIENT_ID +OAUTH_GITHUB_CLIENT_SECRET +OAUTH_GOOGLE_CLIENT_ID +OAUTH_GOOGLE_CLIENT_SECRET +OAUTH_IDPORTEN_CLIENT_ID +OAUTH_SPOTIFY_CLIENT_ID +OAUTH_SPOTIFY_CLIENT_SECRET +```