Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for visitor api key #363

Merged
merged 12 commits into from
Jan 23, 2025
3 changes: 3 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 49 additions & 2 deletions R/connect.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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) {
Expand All @@ -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")
toph-allen marked this conversation as resolved.
Show resolved Hide resolved
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)
Expand Down
12 changes: 8 additions & 4 deletions R/get.R
Original file line number Diff line number Diff line change
Expand Up @@ -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`
#'
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
36 changes: 34 additions & 2 deletions man/connect.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions man/connectapi-package.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions man/get_oauth_credentials.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"access_token": "visitor-api-key",
"issued_token_type": "urn:posit:connect:api-key",
"token_type": "Key"
}
75 changes: 75 additions & 0 deletions tests/testthat/test-connect.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
})
})
Loading