diff --git a/NEWS.md b/NEWS.md index 10220f82..02db3a14 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,9 @@ (#341) - New `get_job_list()` function returns a list of jobs for a content item. (#341) +- New `token` parameter to `connect()` function allows you to create a Connect + client with permissions scoped to the content visitor when running on a Connect + server. (#362) ## Newly deprecated diff --git a/R/connect.R b/R/connect.R index 2a9e5278..73bf17eb 100644 --- a/R/connect.R +++ b/R/connect.R @@ -888,14 +888,31 @@ Connect <- R6::R6Class( #' Create a connection to Posit Connect #' -#' Creates a connection to Posit Connect using the server URL and an api key. +#' Creates a connection to Posit Connect using the server URL and an API key. #' Validates the connection and checks that the version of the server is #' compatible with the current version of the package. #' +#' When running on Connect, the client's environment will contain default +#' `CONNECT_SERVER` and `CONNECT_API_KEY` variables. The API key's permissions +#' are scoped to the publishing user's account. +#' +#' To create a client with permissions scoped to the content visitor's account, +#' call `connect()` passing a user session token from content session headers +#' to the `token` argument. To do this, you must first add a Connect API +#' integration in your published content's Access sidebar. +#' #' @param server The URL for accessing Posit Connect. Defaults to environment #' variable CONNECT_SERVER #' @param api_key The API Key to authenticate to Posit Connect with. Defaults #' to environment variable CONNECT_API_KEY +#' @param token Optional. A user session token. When running on a Connect server, +#' creates a client using the content visitor's account. Running locally, the +#' created client uses the provided API key. +#' @param token_local_testing_key Optional. Only used when not running on +#' Connect and a `token` is provided. By default, the function returns a +#' `Connect` object using the `api_key`. By providing a different +#' key here you can test a visitor client with differently-scoped +#' permissions. #' @param prefix The prefix used to determine environment variables #' @param ... Additional arguments. Not used at present #' @param .check_is_fatal Whether to fail if "check" requests fail. Useful in @@ -907,7 +924,17 @@ Connect <- R6::R6Class( #' #' @examples #' \dontrun{ -#' connect() +#' client <- connect() +#' +#' # Running in Connect, create a client using the content visitor's account. +#' # This example assumes code is being executed in a Shiny app's `server` +#' # function with a `session` object available. +#' token <- session$request$HTTP_POSIT_CONNECT_USER_SESSION_TOKEN +#' client <- connect(token = token) +#' +#' # Test locally with an API key using a different role. +#' fallback_key <- Sys.getenv("VIEWER_ROLE_API_KEY") +#' client <- connect(token = token, token_local_testing_key = fallback_key) #' } #' #' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true") @@ -920,6 +947,8 @@ Connect <- R6::R6Class( connect <- function( server = Sys.getenv(paste0(prefix, "_SERVER"), NA_character_), api_key = Sys.getenv(paste0(prefix, "_API_KEY"), NA_character_), + token, + token_local_testing_key = api_key, prefix = "CONNECT", ..., .check_is_fatal = TRUE) { @@ -933,6 +962,24 @@ connect <- function( } con <- Connect$new(server = server, api_key = api_key) + if (!missing(token)) { + error_if_less_than(con$version, "2025.01.0") + if (on_connect()) { + visitor_creds <- get_oauth_credentials( + con, + user_session_token = token, + requested_token_type = "urn:posit:connect:api-key" + ) + con <- connect(server = server, api_key = visitor_creds$access_token) + } else { + con <- connect(server = server, api_key = token_local_testing_key) + message(paste0( + "Called with `token` but not running on Connect. ", + "Continuing with fallback API key." + )) + } + } + tryCatch( { check_connect_license(con) diff --git a/R/get.R b/R/get.R index 85a8c5d2..32430625 100644 --- a/R/get.R +++ b/R/get.R @@ -579,12 +579,15 @@ get_procs <- function(src) { return(tbl_data) } -#' Perform an OAuth credential exchange to obtain a viewer's OAuth access token. +#' Perform an OAuth credential exchange to obtain a visitor's OAuth access token. #' #' @param connect A Connect R6 object. -#' @param user_session_token The content viewer's session token. This token +#' @param user_session_token The content visitor's session token. This token #' can only be obtained when the content is running on a Connect server. The token #' identifies the user who is viewing the content interactively on the Connect server. +#' @param requested_token_type Optional. You may pass `"urn:posit:connect:api-key"` to +#' request an ephemeral Connect API key scoped to the content visitor's account. +#' #' #' Read this value from the HTTP header: `Posit-Connect-User-Session-Token` #' @@ -612,13 +615,14 @@ get_procs <- function(src) { #' for more information. #' #' @export -get_oauth_credentials <- function(connect, user_session_token) { +get_oauth_credentials <- function(connect, user_session_token, requested_token_type = NULL) { validate_R6_class(connect, "Connect") url <- v1_url("oauth", "integrations", "credentials") body <- list( grant_type = "urn:ietf:params:oauth:grant-type:token-exchange", subject_token_type = "urn:posit:connect:user-session-token", - subject_token = user_session_token + subject_token = user_session_token, + requested_token_type = requested_token_type ) connect$POST( url, diff --git a/R/utils.R b/R/utils.R index 78ada50c..10e692b9 100644 --- a/R/utils.R +++ b/R/utils.R @@ -194,3 +194,9 @@ endpoint_does_not_exist <- function(res) { !("code" %in% names(httr::content(res, as = "parsed"))) ) } + +# Returns `TRUE` if we're running on Connect as determined by the +# `RSTUDIO_PRODUCT` env var, else `FALSE`. +on_connect <- function() { + Sys.getenv("RSTUDIO_PRODUCT") == "CONNECT" +} diff --git a/man/connect.Rd b/man/connect.Rd index 14b48cf3..5367f3b9 100644 --- a/man/connect.Rd +++ b/man/connect.Rd @@ -7,6 +7,8 @@ connect( server = Sys.getenv(paste0(prefix, "_SERVER"), NA_character_), api_key = Sys.getenv(paste0(prefix, "_API_KEY"), NA_character_), + token, + token_local_testing_key = api_key, prefix = "CONNECT", ..., .check_is_fatal = TRUE @@ -19,6 +21,16 @@ variable CONNECT_SERVER} \item{api_key}{The API Key to authenticate to Posit Connect with. Defaults to environment variable CONNECT_API_KEY} +\item{token}{Optional. A user session token. When running on a Connect server, +creates a client using the content visitor's account. Running locally, the +created client uses the provided API key.} + +\item{token_local_testing_key}{Optional. Only used when not running on +Connect and a \code{token} is provided. By default, the function returns a +\code{Connect} object using the \code{api_key}. By providing a different +key here you can test a visitor client with differently-scoped +permissions.} + \item{prefix}{The prefix used to determine environment variables} \item{...}{Additional arguments. Not used at present} @@ -31,13 +43,33 @@ succeed.} A Posit Connect R6 object that can be passed along to methods } \description{ -Creates a connection to Posit Connect using the server URL and an api key. +Creates a connection to Posit Connect using the server URL and an API key. Validates the connection and checks that the version of the server is compatible with the current version of the package. } +\details{ +When running on Connect, the client's environment will contain default +\code{CONNECT_SERVER} and \code{CONNECT_API_KEY} variables. The API key's permissions +are scoped to the publishing user's account. + +To create a client with permissions scoped to the content visitor's account, +call \code{connect()} passing a user session token from content session headers +to the \code{token} argument. To do this, you must first add a Connect API +integration in your published content's Access sidebar. +} \examples{ \dontrun{ -connect() +client <- connect() + +# Running in Connect, create a client using the content visitor's account. +# This example assumes code is being executed in a Shiny app's `server` +# function with a `session` object available. +token <- session$request$HTTP_POSIT_CONNECT_USER_SESSION_TOKEN +client <- connect(token = token) + +# Test locally with an API key using a different role. +fallback_key <- Sys.getenv("VIEWER_ROLE_API_KEY") +client <- connect(token = token, token_local_testing_key = fallback_key) } \dontshow{if (identical(Sys.getenv("IN_PKGDOWN"), "true")) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} diff --git a/man/connectapi-package.Rd b/man/connectapi-package.Rd index 0d63401a..ae8b3f63 100644 --- a/man/connectapi-package.Rd +++ b/man/connectapi-package.Rd @@ -16,17 +16,17 @@ Provides a helpful 'R6' class and methods for interacting with the 'Posit Connec \seealso{ Useful links: \itemize{ - \item \url{https://pkgs.rstudio.com/connectapi/} - \item \url{https://github.com/rstudio/connectapi} - \item Report bugs at \url{https://github.com/rstudio/connectapi/issues} + \item \url{https://posit-dev.github.io/connectapi/} + \item \url{https://github.com/posit-dev/connectapi} + \item Report bugs at \url{https://github.com/posit-dev/connectapi/issues} } Useful links: \itemize{ - \item \url{https://pkgs.rstudio.com/connectapi/} - \item \url{https://github.com/rstudio/connectapi} - \item Report bugs at \url{https://github.com/rstudio/connectapi/issues} + \item \url{https://posit-dev.github.io/connectapi/} + \item \url{https://github.com/posit-dev/connectapi} + \item Report bugs at \url{https://github.com/posit-dev/connectapi/issues} } } diff --git a/man/get_oauth_credentials.Rd b/man/get_oauth_credentials.Rd index 6d12b146..7bf5d056 100644 --- a/man/get_oauth_credentials.Rd +++ b/man/get_oauth_credentials.Rd @@ -2,16 +2,19 @@ % Please edit documentation in R/get.R \name{get_oauth_credentials} \alias{get_oauth_credentials} -\title{Perform an OAuth credential exchange to obtain a viewer's OAuth access token.} +\title{Perform an OAuth credential exchange to obtain a visitor's OAuth access token.} \usage{ -get_oauth_credentials(connect, user_session_token) +get_oauth_credentials(connect, user_session_token, requested_token_type = NULL) } \arguments{ \item{connect}{A Connect R6 object.} -\item{user_session_token}{The content viewer's session token. This token +\item{user_session_token}{The content visitor's session token. This token can only be obtained when the content is running on a Connect server. The token -identifies the user who is viewing the content interactively on the Connect server. +identifies the user who is viewing the content interactively on the Connect server.} + +\item{requested_token_type}{Optional. You may pass \code{"urn:posit:connect:api-key"} to +request an ephemeral Connect API key scoped to the content visitor's account. Read this value from the HTTP header: \code{Posit-Connect-User-Session-Token}} } @@ -19,7 +22,7 @@ Read this value from the HTTP header: \code{Posit-Connect-User-Session-Token}} The OAuth credential exchange response. } \description{ -Perform an OAuth credential exchange to obtain a viewer's OAuth access token. +Perform an OAuth credential exchange to obtain a visitor's OAuth access token. } \details{ Please see https://docs.posit.co/connect/user/oauth-integrations/#obtaining-a-viewer-oauth-access-token diff --git a/tests/testthat/2024.08.0/__api__/v1/oauth/integrations/credentials-a1e6cf-POST.json b/tests/testthat/2024.08.0/__api__/v1/oauth/integrations/credentials-a1e6cf-POST.json new file mode 100644 index 00000000..b3bc152c --- /dev/null +++ b/tests/testthat/2024.08.0/__api__/v1/oauth/integrations/credentials-a1e6cf-POST.json @@ -0,0 +1,5 @@ +{ + "access_token": "visitor-api-key", + "issued_token_type": "urn:posit:connect:api-key", + "token_type": "Key" +} \ No newline at end of file diff --git a/tests/testthat/test-connect.R b/tests/testthat/test-connect.R index 03030d94..faa7df72 100644 --- a/tests/testthat/test-connect.R +++ b/tests/testthat/test-connect.R @@ -115,3 +115,78 @@ test_that("client$version is returns version when server settings exposes it", { expect_equal(con$version, "2024.09.0") }) }) + +test_that("Visitor client can successfully be created running on Connect", { + with_mock_api({ + withr::local_envvar( + CONNECT_SERVER = "https://connect.example", + CONNECT_API_KEY = "fake", + RSTUDIO_PRODUCT = "CONNECT" + ) + + client <- connect(token = "my-token") + + expect_equal( + client$server, + "https://connect.example" + ) + expect_equal( + client$api_key, + "visitor-api-key" + ) + }) +}) + +test_that("Visitor client uses fallback api key when running locally", { + with_mock_api({ + withr::local_envvar( + CONNECT_SERVER = "https://connect.example", + CONNECT_API_KEY = "fake" + ) + + # With default fallback + expect_message( + client <- connect(token = NULL), + "Called with `token` but not running on Connect. Continuing with fallback API key." + ) + + expect_equal( + client$server, + "https://connect.example" + ) + expect_equal( + client$api_key, + "fake" + ) + + # With explicitly-defined fallback + expect_message( + client <- connect(token = NULL, token_local_testing_key = "fallback_fake"), + "Called with `token` but not running on Connect. Continuing with fallback API key." + ) + + expect_equal( + client$server, + "https://connect.example" + ) + expect_equal( + client$api_key, + "fallback_fake" + ) + }) +}) + +test_that("Visitor client code path errs with older Connect version", { + with_mock_dir("2024.09.0", { + withr::local_envvar( + CONNECT_SERVER = "https://connect.example", + CONNECT_API_KEY = "fake", + RSTUDIO_PRODUCT = "CONNECT" + ) + + expect_error( + client <- connect(token = "my-token"), + "This feature requires Posit Connect version 2025.01.0 but you are using 2024.09.0" + ) + }) +})