diff --git a/DESCRIPTION b/DESCRIPTION index 241abfad..f6927aa7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -30,6 +30,7 @@ BugReports: https://github.com/r-lib/gargle/issues Depends: R (>= 3.2) Imports: + base64enc, fs (>= 1.3.1), glue (>= 1.3.0), httr (>= 1.4.0), @@ -39,11 +40,16 @@ Imports: withr Suggests: covr, + htmltools, + jquerylib, knitr, + promises, rmarkdown, sodium, spelling, testthat (>= 2.3.2) +Enhances: + shiny VignetteBuilder: knitr Encoding: UTF-8 diff --git a/NAMESPACE b/NAMESPACE index ff590b18..dbef4493 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,8 @@ export(AuthState) export(Gargle2.0) export(GceToken) +export(basic_welcome_ui) +export(cookie_options) export(cred_funs_add) export(cred_funs_clear) export(cred_funs_list) @@ -23,11 +25,13 @@ export(gargle_oauth_email) export(gargle_oauth_sitrep) export(gargle_oob_default) export(gargle_quiet) +export(google_signin_button) export(init_AuthState) export(oauth_app_from_json) export(request_build) export(request_develop) export(request_make) +export(require_oauth) export(response_as_json) export(response_process) export(tidyverse_api_key) diff --git a/R/AuthState-class.R b/R/AuthState-class.R index 0cdc6b9a..e4ba688d 100644 --- a/R/AuthState-class.R +++ b/R/AuthState-class.R @@ -86,89 +86,147 @@ init_AuthState <- function(package = NA_character_, #' #' @export #' @name AuthState-class -AuthState <- R6::R6Class("AuthState", list( - #' @field package Package name. - package = NULL, - #' @field app An OAuth consumer application. - app = NULL, - #' @field api_key An API key. - api_key = NULL, - #' @field auth_active Logical, indicating whether auth is active. - auth_active = NULL, - #' @field cred Credentials. - cred = NULL, - #' @description Create a new AuthState - #' @details For more details on the parameters, see [init_AuthState()] - initialize = function(package = NA_character_, - app = NULL, - api_key = NULL, - auth_active = TRUE, - cred = NULL) { - ui_line("initializing AuthState") - stopifnot( - is_string(package), - is.null(app) || is.oauth_app(app), - is.null(api_key) || is_string(api_key), - isTRUE(auth_active) || isFALSE(auth_active), - is.null(cred) || inherits(cred, "Token2.0") +AuthState <- R6::R6Class("AuthState", + private = list( + auth_active_ = NULL, + cred_ = NULL + ), + public = list( + #' @field package Package name. + package = NULL, + #' @field app An OAuth consumer application. + app = NULL, + #' @field api_key An API key. + api_key = NULL, + #' @description Create a new AuthState + #' @details For more details on the parameters, see [init_AuthState()] + initialize = function(package = NA_character_, + app = NULL, + api_key = NULL, + auth_active = TRUE, + cred = NULL) { + ui_line("initializing AuthState") + stopifnot( + is_string(package), + is.null(app) || is.oauth_app(app), + is.null(api_key) || is_string(api_key), + isTRUE(auth_active) || isFALSE(auth_active), + is.null(cred) || inherits(cred, "Token2.0") + ) + self$package <- package + self$app <- app + self$api_key <- api_key + self$auth_active <- auth_active + self$cred <- cred + self + }, + #' @description Print an AuthState + #' @param ... Not used. + print = function(...) { + withr::local_options(list(gargle_quiet = FALSE)) + ui_line("") + ui_line(" ", self$package) + ui_line(" ", self$app$appname) + ui_line(" ", obfuscate(self$api_key)) + ui_line(" ", self$auth_active) + ui_line(" ", class(self$cred)[[1]]) + ui_line("---") + }, + #' @description Set the OAuth app + set_app = function(app) { + stopifnot(is.null(app) || is.oauth_app(app)) + self$app <- app + invisible(self) + }, + #' @description Set the API key + #' @param value An API key. + set_api_key = function(value) { + stopifnot(is.null(value) || is_string(value)) + self$api_key <- value + invisible(self) + }, + #' @description Set whether auth is (in)active + #' @param value Logical, indicating whether to send requests authorized with + #' user credentials. + set_auth_active = function(value) { + stopifnot(isTRUE(value) || isFALSE(value)) + self$auth_active <- value + invisible(self) + }, + #' @description Set credentials + #' @param cred User credentials. + set_cred = function(cred) { + self$cred <- cred + invisible(self) + }, + #' @description Clear credentials + clear_cred = function() { + self$set_cred(NULL) + }, + #' @description Get credentials + get_cred = function() { + self$cred + }, + #' @description Report if we have credentials + has_cred = function() { + ## FIXME(jennybc): how should this interact with auth_active? should it? + !is.null(self$cred) + } + ), + active = list( + #' @field auth_active Logical, indicating whether auth is active. + auth_active = function(value) { + invoke_authstate_interceptor("auth_active", value, function(value, fallback) { + if (missing(value)) { + private$auth_active_ + } else { + private$auth_active_ <- value + } + }) + }, + #' @field cred Credentials. + cred = function(value) { + invoke_authstate_interceptor("cred", value, function(value, fallback) { + if (missing(value)) { + private$cred_ + } else { + private$cred_ <- value + } + }) + } + ) +) + +push_authstate_interceptor <- function(auth_active_func, cred_func) { + stopifnot(is.function(auth_active_func)) + stopifnot(is.function(cred_func)) + + gargle_env$cred_access_decorators <- c( + gargle_env$cred_access_decorators, + list( + list(auth_active = auth_active_func, cred = cred_func) ) - self$package <- package - self$app <- app - self$api_key <- api_key - self$auth_active <- auth_active - self$cred <- cred - self - }, - #' @description Print an AuthState - #' @param ... Not used. - print = function(...) { - withr::local_options(list(gargle_quiet = FALSE)) - ui_line("") - ui_line(" ", self$package) - ui_line(" ", self$app$appname) - ui_line(" ", obfuscate(self$api_key)) - ui_line(" ", self$auth_active) - ui_line(" ", class(self$cred)[[1]]) - ui_line("---") - }, - #' @description Set the OAuth app - set_app = function(app) { - stopifnot(is.null(app) || is.oauth_app(app)) - self$app <- app - invisible(self) - }, - #' @description Set the API key - #' @param value An API key. - set_api_key = function(value) { - stopifnot(is.null(value) || is_string(value)) - self$api_key <- value - invisible(self) - }, - #' @description Set whether auth is (in)active - #' @param value Logical, indicating whether to send requests authorized with - #' user credentials. - set_auth_active = function(value) { - stopifnot(isTRUE(value) || isFALSE(value)) - self$auth_active <- value - invisible(self) - }, - #' @description Set credentials - #' @param cred User credentials. - set_cred = function(cred) { - self$cred <- cred - invisible(self) - }, - #' @description Clear credentials - clear_cred = function() { - self$set_cred(NULL) - }, - #' @description Get credentials - get_cred = function() { - self$cred - }, - #' @description Report if we have credentials - has_cred = function() { - ## FIXME(jennybc): how should this interact with auth_active? should it? - !is.null(self$cred) + ) + invisible() +} + +pop_authstate_interceptor <- function() { + stopifnot(length(gargle_env$cred_access_decorators) >= 1) + + gargle_env$cred_access_decorators <- utils::head(gargle_env$cred_access_decorators, -1) + invisible() +} + +invoke_authstate_interceptor <- function(name, value, fallback, i = length(gargle_env$cred_access_decorators)) { + stopifnot(isTRUE(name %in% c("auth_active", "cred"))) + stopifnot(is.function(fallback) && identical(names(formals(fallback)), c("value", "fallback"))) + stopifnot(is.integer(i) && length(i) == 1 && i >= 0) + + if (i == 0L) { + fallback(value, NULL) + } else { + gargle_env$cred_access_decorators[[i]][[name]](value, function(value) { + invoke_authstate_interceptor(name, value, fallback, i - 1L) + }) } -)) +} diff --git a/R/credential-function-registry.R b/R/credential-function-registry.R index 62c21e86..9e76bdf3 100644 --- a/R/credential-function-registry.R +++ b/R/credential-function-registry.R @@ -6,12 +6,16 @@ #' This environment contains: #' * `$cred_funs` is the ordered list of credential functions to use when trying #' to fetch credentials. +#' * `$cred_access_decorators` is the ordered list of list objects, each of +#' which contains `$auth_active` and `$cred` functions, that intercept reads +#' for `AuthState`'s active fields of the same names. #' #' @noRd #' @format An environment. #' @keywords internal gargle_env <- new.env(parent = emptyenv()) gargle_env$cred_funs <- list() +gargle_env$cred_access_decorators <- list() #' Check that f is a viable credential fetching function #' diff --git a/R/shiny-cookies.R b/R/shiny-cookies.R new file mode 100644 index 00000000..0be6b2c0 --- /dev/null +++ b/R/shiny-cookies.R @@ -0,0 +1,212 @@ +read_creds_from_cookies <- function(req, oauth_app) { + cookies <- parse_cookies(req) + gargle_token <- cookies[["gargle_token"]] + if (!is.null(gargle_token)) { + unwrap_creds(gargle_token, oauth_app) + } +} + +wrap_creds <- function(creds, oauth_app) { + cred_str <- jsonlite::toJSON(creds, auto_unbox = TRUE) + + oauth_app_str <- enc2utf8(paste(oauth_app$secret, oauth_app$key)) + + salt <- sodium::random(32) + nonce <- sodium::random(24) + key <- sodium::scrypt(charToRaw(oauth_app_str), salt = salt, size = 32) + # TODO: Add an expiration time (to the encrypted/signed payload), so a + # stolen cookie could only be used for a limited time. + ciphertext <- sodium::data_encrypt(charToRaw(cred_str), key = key, nonce = nonce) + + sodium::bin2hex(c(salt, nonce, ciphertext)) +} + +unwrap_creds <- function(gargle_token, oauth_app) { + if (is.null(gargle_token)) { + return(NULL) + } + + tryCatch({ + oauth_app_str <- paste(oauth_app$secret, oauth_app$key) + + bytes <- sodium::hex2bin(gargle_token) + + if (length(bytes) <= 32 + 24) { + stop(call. = FALSE, "gargle cookie payload was too short") + } + + salt <- bytes[1:32] + nonce <- bytes[32 + (1:24)] + rest <- utils::tail(bytes, -(32 + 24)) + + key <- sodium::scrypt(charToRaw(oauth_app_str), salt = salt, size = 32) + cleartext <- sodium::data_decrypt(rest, key = key, nonce = nonce) + cleartext <- rawToChar(cleartext) + Encoding(cleartext) <- "UTF-8" + + creds <- jsonlite::parse_json(cleartext) + + email <- jwt_decode(creds[["id_token"]])[["claim"]][["email"]] + stopifnot(is.character(email) && length(email) == 1) + + token <- gargle2.0_token(email, oauth_app, package = "gargle", + scope = creds$scope, credentials = creds) + + if (!token$validate()) { + token$refresh() + } + + token + }, error = function(err) { + ui_line("gargle cookie failed to decrypt: ", conditionMessage(err)) + return(NULL) + }) +} + +# Returns a named list of cookies that are present in this request; or `NULL` if +# no cookie header was found, or the cookie header was malformed. +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)) + + stats::setNames(as.list(values), names) +} + +#' HTTP cookie options +#' +#' @description +#' Creates a cookie options object that can be passed to [require_oauth()], +#' to use when writing HTTP cookies for persisting auth credentials. +#' +#' @param max_age Either `NULL` or a number indicating how many seconds (after a +#' cookie is set) until the cookie expires. If both `expires` and `max_age` +#' are `NULL`, then the cookie will be removed when the browser shuts down. +#' @param http_only Either `NULL` or `TRUE`, which indicates that the cookie +#' should not be readable by JavaScript code in the client, only by the server +#' (the R process running Shiny). You should use `TRUE` unless you know you +#' have a specific reason not to. +#' @param domain,path,secure,same_site Standard HTTP cookie options; see +#' [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) +#' for details. +#' @param expires A [POSIXt][base::POSIXt] (`POSIXlt` or `POSIXct`) object +#' specifying the time at which the cookie expires. (You'll generally want to +#' use `max_age` instead; and if both `max_age` and `expires` are both +#' specified, `max_age` takes precedence.) +#' +#' @export +cookie_options <- function(max_age = NULL, domain = NULL, path = NULL, + secure = NULL, http_only = TRUE, same_site = NULL, 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 + ) +} + +# Returns a list, suitable for `!!!`-ing into a list of HTTP headers +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)) + ui_line("Set-Cookie: ", header) + 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) +} + +# A JWT token is just a couple of JSON blobs, each base64 encoded, then joined +# with "." characters as delimiters. (There's a signature too, but this function +# ignores it. See the {jose} package if signature verification is needed.) +jwt_decode <- function(jwt_str) { + stopifnot(is.character(jwt_str) && length(jwt_str) == 1) + pieces <- strsplit(jwt_str, ".", fixed = TRUE)[[1]] + stopifnot(length(pieces) == 3) + + list( + header = jsonlite::parse_json(rawToChar(base64enc::base64decode(pieces[[1]]))), + claim = jsonlite::parse_json(rawToChar(base64enc::base64decode(pieces[[2]]))) + ) +} + diff --git a/R/shiny-env.R b/R/shiny-env.R new file mode 100644 index 00000000..110f2114 --- /dev/null +++ b/R/shiny-env.R @@ -0,0 +1,106 @@ +## Functions that modify the gargle environment for Shiny purposes + +install_shiny_authstate_interceptor <- function(shiny, onStop) { + push_authstate_interceptor( + auth_active_func = function(value, fallback) { + if (missing(value)) { + !is.null(shiny_token()) + } else { + fallback(value) + } + }, + cred_func = function(value, fallback) { + if (missing(value)) { + shiny_token() + } else { + fallback(value) + } + } + ) + + shiny::onStop(function() { + pop_authstate_interceptor() + }, session = NULL) +} + +suppress_token_fetch <- function(shiny, onStop) { + cred_funs <- cred_funs_list() + cred_funs_clear() + cred_funs_add(shiny = function(scopes, ...) { + args <- list(...) + pkg <- if (!is.null(args$package)) { + paste("The", args$package, "package") + } else { + "A package" + } + message( + pkg, " tried to access Google credentials without consulting Shiny. ", + "This operation will fail! Try upgrading that package to the latest ", + "version." + ) + NULL + }) + shiny::onStop(function() { + cred_funs_set(cred_funs) + }) +} + +shiny_token <- function(session = shiny::getDefaultReactiveDomain()) { + if (!is.null(session)) { + session$userData$gargle_token + } else { + NULL + } +} + +with_shiny_token <- function(token, expr) { + force(token) + + on <- function() { + push_authstate_interceptor( + auth_active_func = function(value, fallback) { + if (missing(value)) { + !is.null(token) + } else { + fallback(value) + } + }, + cred_func = function(value, fallback) { + if (missing(value)) { + token + } else { + fallback(value) + } + } + ) + } + off <- pop_authstate_interceptor + + domain <- promises::new_promise_domain( + wrapOnFulfilled = function(onFulfilled) { + function(...) { + on() + on.exit(off(), add = TRUE) + + onFulfilled(...) + } + }, + wrapOnRejected = function(onRejected) { + function(...) { + on() + on.exit(off, add = TRUE) + + onRejected(...) + } + }, + wrapSync = function(expr) { + on() + on.exit(off, add = TRUE) + + expr + } + ) + + promises::with_promise_domain(domain, expr) +} + diff --git a/R/shiny-http-handlers.R b/R/shiny-http-handlers.R new file mode 100644 index 00000000..d566504f --- /dev/null +++ b/R/shiny-http-handlers.R @@ -0,0 +1,167 @@ +## This file contains functions that serve as Shiny HTTP handlers. Each function +## has one purpose; they are used in combination within require_oauth(). + +# Handle /logout, by revoking the user's Google token and clearing cookies +handle_logout <- function(req, oauth_app, cookie_opts) { + if (!isTRUE(req$PATH_INFO == "/logout")) { + return(NULL) + } + + token <- read_creds_from_cookies(req, oauth_app) + if (!is.null(token)) { + tryCatch( + { + token$revoke() + ui_line("Token successfully revoked") + }, + error = function(e) { + message("Error while revoking token for logout: ", conditionMessage(e)) + } + ) + } else { + ui_line("Logout called but no (valid) credential cookie detected") + } + + shiny::httpResponse( + status = 307L, + content_type = NULL, + content = "", + headers = rlang::list2( + Location = "./", + "Cache-Control" = "no-store", + !!!delete_cookie_header("gargle_auth_state", cookie_opts), + !!!delete_cookie_header("gargle_token", cookie_opts) + ) + ) +} + +# Handle OAuth callback from Google, by: +# 1. Verifying the `state` param matches what's in the cookie; prevents spoofing +# 2. Using the `code` to retrieve the access token +# 3. Setting the token as a signed and encrypted cookie +handle_oauth_callback <- function(req, oauth_app, cookie_opts) { + qs <- shiny::parseQueryString(req[["QUERY_STRING"]]) + has_code_param <- "code" %in% names(qs) + + if (!has_code_param) { + return(NULL) + } + + # User just completed login; verify, set cookie, and redirect + cookies <- parse_cookies(req) + gargle_auth_state <- cookies[["gargle_auth_state"]] + if (is.null(gargle_auth_state)) { + return(NULL) + } + + code <- qs[["code"]] + state <- qs[["state"]] + + if (!identical(state, gargle_auth_state)) { + ui_line("state parameter mismatch") + return(NULL) + } + + # Consider making this async + cred <- httr::oauth2.0_access_token( + gargle_outh_endpoint(), + app = oauth_app, + code = code, + redirect_uri = infer_app_url(req) + ) + + return(shiny::httpResponse( + status = 307L, + content_type = NULL, + content = "", + headers = rlang::list2( + Location = infer_app_url(req), + "Cache-Control" = "no-store", + !!!delete_cookie_header("gargle_auth_state", cookie_opts), + !!!set_cookie_header("gargle_token", wrap_creds(cred, oauth_app), + cookie_opts) + ) + )) +} + +# Handle requests where a valid auth token is attached to the request (as a +# cookie), by letting the user through to the app +handle_logged_in <- function(req, oauth_app, httpHandler) { + token <- read_creds_from_cookies(req, oauth_app) + if (!is.null(token)) { + # TODO: If token is expired, refresh and rewrite the cookie + + # User is already logged in, proceed + with_shiny_token(token, { + httpHandler(req) + }) + } +} + +# Handle requests for the app homepage when the user is not logged in. If a +# welcome UI is provided, it's displayed, otherwise we redirect to Google login. +handle_welcome <- function(req, welcome_ui, oauth_app, scopes, cookie_opts) { + # We don't want to match on, say, favicon.ico. Each request that we handle in + # this handler will cause the gargle_auth_state to be written with a new + # value, ovewriting the previous gargle_auth_state! So, if the browser + # requests "/" and then "/favicon.ico", then the login link for "/" will fail + # to work (since it embeds a state value that no longer matches the + # gargle_auth_state cookie). + if (!isTRUE(req$PATH_INFO == "/")) { + return(NULL) + } + + # When we're done, redirect back to our own URL. It takes some work to figure + # out what "our own URL" means. + redirect_uri <- infer_app_url(req) + state <- sodium::bin2hex(sodium::random(32)) + query_extra <- list( + access_type = "offline" + ) + + auth_url <- httr::oauth2.0_authorize_url( + endpoint = gargle_outh_endpoint(), + oauth_app, + scope = paste(scopes, collapse = " "), + redirect_uri = redirect_uri, + state = state, + query_extra = query_extra) + + if (is.null(welcome_ui)) { + shiny::httpResponse( + status = 307L, + content_type = NULL, + content = "", + headers = rlang::list2( + Location = auth_url, + "Cache-Control" = "no-store", + !!!set_cookie_header("gargle_auth_state", state, cookie_opts) + ) + ) + } else { + ui <- welcome_ui(req = req, login_url = auth_url) + if (inherits(ui, "httpResponse")) { + ui + } else { + lang <- attr(ui, "lang", exact = TRUE) %||% "en" + if (!(inherits(ui, "shiny.tag") && ui$name == "body")) { + ui <- htmltools::tags$body(ui) + } + doc <- htmltools::htmlTemplate( + system.file("shiny", "default.html", package = "gargle"), + lang = lang, + body = ui, + document_ = TRUE + ) + html <- htmltools::renderDocument(doc, processDep = shiny::createWebDependency) + shiny::httpResponse( + status = 403L, + content = html, + headers = rlang::list2( + "Cache-Control" = "no-store", + !!!set_cookie_header("gargle_auth_state", state, cookie_opts) + ) + ) + } + } +} diff --git a/R/shiny-ui.R b/R/shiny-ui.R new file mode 100644 index 00000000..1d9d7459 --- /dev/null +++ b/R/shiny-ui.R @@ -0,0 +1,55 @@ +#' Helper function for creating a basic welcome screen +#' +#' Call this function with a bit of content (say, the title of your app and a +#' couple of sentences describing why login is required) and a `welcome_ui` +#' function will be returned, suitable for passing to [require_oauth()]. (See +#' the Details section of [require_oauth()] to see an example of +#' `basic_welcome_ui`.) +#' +#' @param ... _Unnamed_ arguments should be Shiny UI objects (i.e. HTML tags), and +#' will become the page's main contents; they will immediately be followed by +#' a Google signin button. +#' +#' _Named_ arguments become attributes on the innermost +#' `
` element that wraps both the given content, and the sign-in button. +#' +#' @export +basic_welcome_ui <- function(...) { + function(req, login_url) { + htmltools::tagList( + jquerylib::jquery_core(), + shiny::fluidPage( + shiny::fluidRow( + shiny::column(6, offset = 3, class = "text-center", + ..., + htmltools::p(google_signin_button(login_url)) + ) + ) + ) + ) + } +} + +#' @export +google_signin_button <- function(login_url, ..., theme = c("light", "dark"), + aria_label = "Sign in with Google") { + + stopifnot(is.character(login_url) && length(login_url) == 1) + theme <- match.arg(theme) + + dep <- htmltools::htmlDependency( + "google-sign-in-button-styles", + "1.0", + src = "branding", + package = "gargle", + all_files = TRUE, + stylesheet = "signin.css" + ) + htmltools::tagList( + dep, + htmltools::tags$a(href = login_url, class = paste0("google-signin-button-", theme), + "aria-label" = aria_label, + ... + ) + ) +} diff --git a/R/shiny.R b/R/shiny.R new file mode 100644 index 00000000..a1b8b423 --- /dev/null +++ b/R/shiny.R @@ -0,0 +1,185 @@ +#' Require OAuth login for Shiny app +#' +#' @description +#' Use this function to enforce Google Auth login for all visitors to a Shiny +#' app. Once logged in, a [token][Gargle-class] will be stored on the Shiny +#' session object and automatically used for any Google API operations that go +#' through gargle. +#' +#' @param app The return value from [shiny::shinyApp()]. For readability, +#' consider using a pipe operator, i.e. `shinyApp() %>% require_oauth(...)`. +#' @param oauth_app An [httr::oauth_app()] object that provides the OAuth client +#' ID and secret. See the [How to get your own API +#' credentials](https://gargle.r-lib.org/articles/get-api-credentials.html) +#' vignette and [oauth_app_from_json()]. +#' @inheritParams token_fetch +#' @param welcome_ui A function that provides the UI to be displayed when a user +#' tries to visit the app without being logged in. See the "Welcome UI" +#' section below. +#' @param cookie_opts `require_oauth` uses an HTTP cookie to remember login +#' credentials between visits. Use this parameter to control aspects of the +#' cookie, such as maximum age (defaults to the duration of the browser +#' process). +#' +#' @section Welcome UI: +#' +#' You can use the `welcome_ui` parameter to customize the page that greets +#' users before they log in. With the default value of `NULL`, users will not +#' see a welcome message, but instead be immediately directed to a Google +#' sign-in page. +#' +#' If you want to welcome the user with some instructions, or at least an +#' indication of what app they're logging into, the simplest way is to use the +#' [basic_welcome_ui()] function. This will create a [shiny::fluidPage()] and +#' put whatever UI you pass it into a centered div; and below that, a Google +#' sign-in button. +#' +#' Here's an example with a simple headline and one-line welcome message: +#' +#' ```r +#' welcome <- basic_welcome_ui( +#' h2("Welcome!"), +#' p("To use this app, please sign in with a Google account.") +#' ) +#' shinyApp(ui, server) %>% require_oauth(oauth_app, scopes, welcome_ui = welcome) +#' ``` +#' +#' ![](basic_welcome_ui.png "Basic welcome UI") +#' +#' You can also provide a completely custom welcome page. To do so, pass a +#' function that takes two parameters: `req` and `login_url`. The `req` +#' parameter will be a [Rook](https://github.com/jeffreyhorner/Rook) +#' environment, and can generally be ignored. The `login_url` parameter is the +#' URL the user should be directed to when they're ready to log in; this +#' should be turned into a link or button (see [google_signin_button()]). +#' +#' ```r +#' welcome <- function(req, login_url) { +#' fluidPage(theme = shinythemes::shinytheme("darkly"), +#' div(style = "padding: 3rem;", +#' h3("Sign in to continue"), +#' google_signin_button(login_url, theme = "dark") +#' ) +#' ) +#' } +#' shinyApp(ui, server) %>% require_oauth(oauth_app, scopes, welcome_ui = welcome) +#' ``` +#' +#' ![](custom_welcome_ui.png "Custom welcome UI") +#' +#' @export +require_oauth <- function(app, oauth_app, scopes, welcome_ui = NULL, + cookie_opts = cookie_options(http_only = TRUE)) { + + # 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 a valid Google token. + # + # It also modifies the gargle environment so that if gargle-derived packages + # look for tokens from their internal .auth (AuthState), they are given the + # token that Shiny knows about. + + # Force and normalize arguments + force(app) + force(oauth_app) + scopes <- normalize_scopes(add_email_scope(scopes)) + force(welcome_ui) + force(cookie_opts) + + # 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 the token and deletes cookies + handle_logout(req, oauth_app, cookie_opts) %||% + # Handles callback redirect from Google (after user logs in successfully) + # and sets gargle cookies + handle_oauth_callback(req, oauth_app, cookie_opts) %||% + # Handles requests that have good gargle cookies; shows the actual app + handle_logged_in(req, oauth_app, httpHandler) %||% + # If we get here, the user isn't logged in; show them welcome_ui if + # non-NULL, or else send them straight to Google + handle_welcome(req, welcome_ui, oauth_app, scopes, cookie_opts) + 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 <- read_creds_from_cookies(session$request, oauth_app) + if (is.null(token)) { + stop("No valid OAuth token was found on the websocket connection") + } else { + session$userData$gargle_token <- token + wrappedServer(input, output, session) + } + } + } + + onStart <- app$onStart + app$onStart <- function() { + + install_shiny_authstate_interceptor() + suppress_token_fetch() + + # Call original onStart, if any + if (is.function(onStart)) { + onStart() + } + } + + app +} + + +infer_app_url <- function(req) { + + url <- + # Connect + req[["HTTP_X_RSC_REQUEST"]] %||% + req[["HTTP_RSTUDIO_CONNECT_APP_BASE_URL"]] %||% + # ShinyApps.io + if (!is.null(req[["HTTP_X_REDX_FRONTEND_NAME"]])) { paste0("https://", req[["HTTP_X_REDX_FRONTEND_NAME"]]) } + + if (is.null(url)) { + forwarded_host <- req[["HTTP_X_FORWARDED_HOST"]] + forwarded_port <- req[["HTTP_X_FORWARDED_PORT"]] + + host <- if (!is.null(forwarded_host) && !is.null(forwarded_port)) { + paste0(forwarded_host, ":", forwarded_port) + } else { + req[["HTTP_HOST"]] %||% paste0(req[["SERVER_NAME"]], ":", req[["SERVER_PORT"]]) + } + + proto <- req[["HTTP_X_FORWARDED_PROTO"]] %||% req[["rook.url_scheme"]] + + if (tolower(proto) == "http") { + host <- sub(":80$", "", host) + } else if (tolower(proto) == "https") { + host <- sub(":443$", "", host) + } + + url <- paste0( + proto, + "://", + host, + req[["SCRIPT_NAME"]], + req[["PATH_INFO"]] + ) + } + + # Strip existing querystring, if any + url <- sub("\\?.*", "", url) + + url +} diff --git a/inst/branding/google_signin_dark_disabled_web@2x.png b/inst/branding/google_signin_dark_disabled_web@2x.png new file mode 100644 index 00000000..485757f3 Binary files /dev/null and b/inst/branding/google_signin_dark_disabled_web@2x.png differ diff --git a/inst/branding/google_signin_dark_focus_web@2x.png b/inst/branding/google_signin_dark_focus_web@2x.png new file mode 100644 index 00000000..369a6ca2 Binary files /dev/null and b/inst/branding/google_signin_dark_focus_web@2x.png differ diff --git a/inst/branding/google_signin_dark_normal_web@2x.png b/inst/branding/google_signin_dark_normal_web@2x.png new file mode 100644 index 00000000..f27bb243 Binary files /dev/null and b/inst/branding/google_signin_dark_normal_web@2x.png differ diff --git a/inst/branding/google_signin_dark_pressed_web@2x.png b/inst/branding/google_signin_dark_pressed_web@2x.png new file mode 100644 index 00000000..4cb85e9b Binary files /dev/null and b/inst/branding/google_signin_dark_pressed_web@2x.png differ diff --git a/inst/branding/google_signin_light_disabled_web@2x.png b/inst/branding/google_signin_light_disabled_web@2x.png new file mode 100644 index 00000000..485757f3 Binary files /dev/null and b/inst/branding/google_signin_light_disabled_web@2x.png differ diff --git a/inst/branding/google_signin_light_focus_web@2x.png b/inst/branding/google_signin_light_focus_web@2x.png new file mode 100644 index 00000000..510e6192 Binary files /dev/null and b/inst/branding/google_signin_light_focus_web@2x.png differ diff --git a/inst/branding/google_signin_light_normal_web@2x.png b/inst/branding/google_signin_light_normal_web@2x.png new file mode 100644 index 00000000..c1e2c5c7 Binary files /dev/null and b/inst/branding/google_signin_light_normal_web@2x.png differ diff --git a/inst/branding/google_signin_light_pressed_web@2x.png b/inst/branding/google_signin_light_pressed_web@2x.png new file mode 100644 index 00000000..d01521e8 Binary files /dev/null and b/inst/branding/google_signin_light_pressed_web@2x.png differ diff --git a/inst/branding/signin.css b/inst/branding/signin.css new file mode 100644 index 00000000..1ef93f56 --- /dev/null +++ b/inst/branding/signin.css @@ -0,0 +1,34 @@ +.google-signin-button-light { + display: inline-block; + width: 191px; + height: 46px; + background-image: url(google_signin_light_normal_web@2x.png); + background-repeat: no-repeat; + background-size: 191px 46px; + outline: none; +} + +.google-signin-button-light:focus { + background-image: url(google_signin_light_focus_web@2x.png); +} + +.google-signin-button-light:active { + background-image: url(google_signin_light_pressed_web@2x.png); +} + +.google-signin-button-dark { + display: inline-block; + width: 191px; + height: 46px; + background-image: url(google_signin_dark_normal_web@2x.png); + background-repeat: no-repeat; + background-size: 191px 46px; +} + +.google-signin-button-dark:focus { + background-image: url(google_signin_dark_focus_web@2x.png); +} + +.google-signin-button-dark:active { + background-image: url(google_signin_dark_pressed_web@2x.png); +} diff --git a/inst/shiny-example/app.R b/inst/shiny-example/app.R new file mode 100644 index 00000000..f043c70e --- /dev/null +++ b/inst/shiny-example/app.R @@ -0,0 +1,44 @@ +library(shiny) +library(googledrive) +library(gargle) +library(magrittr) + +oauth_scopes = c( + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.readonly" +) + +# DO NOT DEPLOY using `gargle_app`--it is for testing purposes only! Instead, +# get your own API credentials from Google and call httr::oauth_app(). +# See https://gargle.r-lib.org/articles/get-api-credentials.html +oauth_app <- gargle_app() + +# What people will see before they log in +welcome <- basic_welcome_ui( + h2("Welcome!"), + p("To use this app, please sign in with a Google account.") +) + +# UI to be displayed after login. You can call Google APIs from here. +ui <- function(req) { + fluidPage( + absolutePanel(top = 5, right = 5, + "Logged in as ", + gargle::token_email(googledrive::drive_token()), + " | ", + tags$a(href = "logout", "Log out") + ), + verbatimTextOutput("foo") + ) +} + +# Server logic to be loaded after login. You can call Google APIs from here. +server <- function(input, output, session) { + output$foo <- renderText({ + listing <- googledrive::drive_find(n_max = 100) + paste(collapse = "\n", capture.output(print(listing))) + }) +} + +# shinyApp object is piped to require_oauth +shinyApp(ui, server) %>% require_oauth(oauth_app, oauth_scopes, welcome_ui = welcome) 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/man/AuthState-class.Rd b/man/AuthState-class.Rd index 61132545..dd6dd03a 100644 --- a/man/AuthState-class.Rd +++ b/man/AuthState-class.Rd @@ -44,7 +44,12 @@ An \code{AuthState} should be created through the constructor function \item{\code{app}}{An OAuth consumer application.} \item{\code{api_key}}{An API key.} - +} +\if{html}{\out{
}} +} +\section{Active bindings}{ +\if{html}{\out{
}} +\describe{ \item{\code{auth_active}}{Logical, indicating whether auth is active.} \item{\code{cred}}{Credentials.} diff --git a/man/basic_welcome_ui.Rd b/man/basic_welcome_ui.Rd new file mode 100644 index 00000000..40aec172 --- /dev/null +++ b/man/basic_welcome_ui.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shiny-ui.R +\name{basic_welcome_ui} +\alias{basic_welcome_ui} +\title{Helper function for creating a basic welcome screen} +\usage{ +basic_welcome_ui(...) +} +\arguments{ +\item{...}{\emph{Unnamed} arguments should be Shiny UI objects (i.e. HTML tags), and +will become the page's main contents; they will immediately be followed by +a Google signin button. + +\emph{Named} arguments become attributes on the innermost +\verb{
} element that wraps both the given content, and the sign-in button.} +} +\description{ +Call this function with a bit of content (say, the title of your app and a +couple of sentences describing why login is required) and a \code{welcome_ui} +function will be returned, suitable for passing to \code{\link[=require_oauth]{require_oauth()}}. (See +the Details section of \code{\link[=require_oauth]{require_oauth()}} to see an example of +\code{basic_welcome_ui}.) +} diff --git a/man/cookie_options.Rd b/man/cookie_options.Rd new file mode 100644 index 00000000..eb3073a5 --- /dev/null +++ b/man/cookie_options.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shiny-cookies.R +\name{cookie_options} +\alias{cookie_options} +\title{HTTP cookie options} +\usage{ +cookie_options( + max_age = NULL, + domain = NULL, + path = NULL, + secure = NULL, + http_only = TRUE, + same_site = NULL, + expires = NULL +) +} +\arguments{ +\item{max_age}{Either \code{NULL} or a number indicating how many seconds (after a +cookie is set) until the cookie expires. If both \code{expires} and \code{max_age} +are \code{NULL}, then the cookie will be removed when the browser shuts down.} + +\item{domain, path, secure, same_site}{Standard HTTP cookie options; see +\href{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie}{MDN} +for details.} + +\item{http_only}{Either \code{NULL} or \code{TRUE}, which indicates that the cookie +should not be readable by JavaScript code in the client, only by the server +(the R process running Shiny). You should use \code{TRUE} unless you know you +have a specific reason not to.} + +\item{expires}{A \link[base:DateTimeClasses]{POSIXt} (\code{POSIXlt} or \code{POSIXct}) object +specifying the time at which the cookie expires. (You'll generally want to +use \code{max_age} instead; and if both \code{max_age} and \code{expires} are both +specified, \code{max_age} takes precedence.)} +} +\description{ +Creates a cookie options object that can be passed to \code{\link[=require_oauth]{require_oauth()}}, +to use when writing HTTP cookies for persisting auth credentials. +} diff --git a/man/figures/basic_welcome_ui.png b/man/figures/basic_welcome_ui.png new file mode 100644 index 00000000..b40829d5 Binary files /dev/null and b/man/figures/basic_welcome_ui.png differ diff --git a/man/figures/basic_welcome_ui_large.png b/man/figures/basic_welcome_ui_large.png new file mode 100644 index 00000000..dccbdd17 Binary files /dev/null and b/man/figures/basic_welcome_ui_large.png differ diff --git a/man/figures/custom_welcome_ui.png b/man/figures/custom_welcome_ui.png new file mode 100644 index 00000000..7f2b7eb8 Binary files /dev/null and b/man/figures/custom_welcome_ui.png differ diff --git a/man/figures/custom_welcome_ui_large.png b/man/figures/custom_welcome_ui_large.png new file mode 100644 index 00000000..ec78fb5e Binary files /dev/null and b/man/figures/custom_welcome_ui_large.png differ diff --git a/man/require_oauth.Rd b/man/require_oauth.Rd new file mode 100644 index 00000000..76b195ad --- /dev/null +++ b/man/require_oauth.Rd @@ -0,0 +1,89 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shiny.R +\name{require_oauth} +\alias{require_oauth} +\title{Require OAuth login for Shiny app} +\usage{ +require_oauth( + app, + oauth_app, + scopes, + welcome_ui = NULL, + cookie_opts = cookie_options(http_only = TRUE) +) +} +\arguments{ +\item{app}{The return value from \code{\link[shiny:shinyApp]{shiny::shinyApp()}}. For readability, +consider using a pipe operator, i.e. \code{shinyApp() \%>\% require_oauth(...)}.} + +\item{oauth_app}{An \code{\link[httr:oauth_app]{httr::oauth_app()}} object that provides the OAuth client +ID and secret. See the \href{https://gargle.r-lib.org/articles/get-api-credentials.html}{How to get your own API credentials} +vignette and \code{\link[=oauth_app_from_json]{oauth_app_from_json()}}.} + +\item{scopes}{A character vector of scopes to request. Pick from those listed +at \url{https://developers.google.com/identity/protocols/googlescopes}. + +For certain token flows, the +\code{"https://www.googleapis.com/auth/userinfo.email"} scope is unconditionally +included. This grants permission to retrieve the email address associated +with a token; gargle uses this to index cached OAuth tokens. This grants no +permission to view or send email. It is considered a low value scope and +does not appear on the consent screen.} + +\item{welcome_ui}{A function that provides the UI to be displayed when a user +tries to visit the app without being logged in. See the "Welcome UI" +section below.} + +\item{cookie_opts}{\code{require_oauth} uses an HTTP cookie to remember login +credentials between visits. Use this parameter to control aspects of the +cookie, such as maximum age (defaults to the duration of the browser +process).} +} +\description{ +Use this function to enforce Google Auth login for all visitors to a Shiny +app. Once logged in, a \link[=Gargle-class]{token} will be stored on the Shiny +session object and automatically used for any Google API operations that go +through gargle. +} +\section{Welcome UI}{ + + +You can use the \code{welcome_ui} parameter to customize the page that greets +users before they log in. With the default value of \code{NULL}, users will not +see a welcome message, but instead be immediately directed to a Google +sign-in page. + +If you want to welcome the user with some instructions, or at least an +indication of what app they're logging into, the simplest way is to use the +\code{\link[=basic_welcome_ui]{basic_welcome_ui()}} function. This will create a \code{\link[shiny:fluidPage]{shiny::fluidPage()}} and +put whatever UI you pass it into a centered div; and below that, a Google +sign-in button. + +Here's an example with a simple headline and one-line welcome message:\if{html}{\out{
}}\preformatted{welcome <- basic_welcome_ui( + h2("Welcome!"), + p("To use this app, please sign in with a Google account.") +) +shinyApp(ui, server) \%>\% require_oauth(oauth_app, scopes, welcome_ui = welcome) +}\if{html}{\out{
}} + +\figure{basic_welcome_ui.png}{Basic welcome UI} + +You can also provide a completely custom welcome page. To do so, pass a +function that takes two parameters: \code{req} and \code{login_url}. The \code{req} +parameter will be a \href{https://github.com/jeffreyhorner/Rook}{Rook} +environment, and can generally be ignored. The \code{login_url} parameter is the +URL the user should be directed to when they're ready to log in; this +should be turned into a link or button (see \code{\link[=google_signin_button]{google_signin_button()}}).\if{html}{\out{
}}\preformatted{welcome <- function(req, login_url) \{ + fluidPage(theme = shinythemes::shinytheme("darkly"), + div(style = "padding: 3rem;", + h3("Sign in to continue"), + google_signin_button(login_url, theme = "dark") + ) + ) +\} +shinyApp(ui, server) \%>\% require_oauth(oauth_app, scopes, welcome_ui = welcome) +}\if{html}{\out{
}} + +\figure{custom_welcome_ui.png}{Custom welcome UI} +} + diff --git a/vignettes/articles/managing-tokens-securely.Rmd b/vignettes/articles/managing-tokens-securely.Rmd index 45b780de..f75163de 100644 --- a/vignettes/articles/managing-tokens-securely.Rmd +++ b/vignettes/articles/managing-tokens-securely.Rmd @@ -96,13 +96,17 @@ Make sure `.Renviron` ends in a newline; the lack of this is a notorious cause o ### Provide environment variable to other services +#### RStudio Connect: + +After publishing an R Markdown report or Shiny application on RStudio Connect, you'll see the Content Settings Panel to the right of whatever you just published. Use the [Vars tab](https://docs.rstudio.com/connect/user/content-settings/#content-vars) to create a `PACKAGE_PASSWORD` environment variable. + #### GitHub Actions: Define the environment variable as an encrypted secret in your repo: -Use the secrets context to expose a secret as an environment variable in your workflows. That will look like like so, in some appropriate place in your workflow file: +Use the secrets context to expose a secret as an environment variable in your workflows. That will look like so, in some appropriate place in your workflow file: ``` env: diff --git a/vignettes/gargle-auth-in-client-package.Rmd b/vignettes/gargle-auth-in-client-package.Rmd index bfdf1b8a..080c1c8d 100644 --- a/vignettes/gargle-auth-in-client-package.Rmd +++ b/vignettes/gargle-auth-in-client-package.Rmd @@ -65,17 +65,27 @@ drive_auth <- function(email = gargle::gargle_oauth_email(), A client package can use an internal object of class `gargle::AuthClass` to hold the auth state. Here's how it is initialized in googledrive: ```{r eval = FALSE} -.auth <- gargle::init_AuthState( - package = "googledrive", - auth_active = TRUE - # app = NULL, - # api_key = NULL, - # cred = NULL -) +.auth <- NULL + +.onLoad <- function(libname, pkgname) { + .auth <<- gargle::init_AuthState( + package = "googledrive", + auth_active = TRUE + # app = NULL, + # api_key = NULL, + # cred = NULL + ) +} ``` +(If your package already has an `.onLoad` function defined, add the `.auth <<- gargle::init_AuthState(...)` to that one instead of creating a new one.) + The OAuth `app` and `api_key` are configurable by the user and, when `NULL`, downstream functions can fall back to internal credentials. The `cred` field is populated by the first call to `drive_auth()` (direct or indirectly via `drive_token()`). +It might seem strange that `.auth` is declared with `NULL`, then populated in `.onLoad()`. Why not simply write `.auth <- gargle::init_AuthState(...)` at the top level? In fact, earlier versions of this article recommended just that. This is a problem because "the R objects of the package are created at installation time and stored in a database in the R directory of the installed package, being loaded into the session at first use"[^1]. Since `AuthState` is an R6 class declared in gargle, and `.auth` is in googledrive, you'd end up with code from the gargle package being stored in the googledrive package's database. That can cause big problems if you install a new copy of gargle that has made changes to the `AuthState` implementation---googledrive will continue using the old `AuthState` implementation! Follow the `.onLoad()` pattern here, and this issue disappears. + +[^1]: https://cran.r-project.org/doc/manuals/r-release/R-exts.html#Creating-R-packages + ### OAuth app Most users should present OAuth user credentials to Google APIs. However, most users can also be spared the fiddly details surrounding this. The OAuth app is one example. The app is a component that most users do not even know about and they are content to use the same app for all work through a client package: possibly, the app built into the package.